작업계획서대로 진행
This commit is contained in:
30
backend/api/business-report/[id]/confirm.put.ts
Normal file
30
backend/api/business-report/[id]/confirm.put.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 주간보고 확정
|
||||
* PUT /api/business-report/[id]/confirm
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessReportId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT * FROM wr_business_weekly_report WHERE business_report_id = $1
|
||||
`, [businessReportId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (existing.status === 'confirmed') {
|
||||
throw createError({ statusCode: 400, message: '이미 확정된 보고서입니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_business_weekly_report SET
|
||||
status = 'confirmed',
|
||||
updated_at = NOW()
|
||||
WHERE business_report_id = $1
|
||||
`, [businessReportId])
|
||||
|
||||
return { success: true, message: '보고서가 확정되었습니다.' }
|
||||
})
|
||||
80
backend/api/business-report/[id]/detail.get.ts
Normal file
80
backend/api/business-report/[id]/detail.get.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { query, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 주간보고 상세 조회
|
||||
* GET /api/business-report/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessReportId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const report = await queryOne(`
|
||||
SELECT
|
||||
br.*,
|
||||
b.business_name,
|
||||
e.employee_name as created_by_name
|
||||
FROM wr_business_weekly_report br
|
||||
JOIN wr_business b ON br.business_id = b.business_id
|
||||
LEFT JOIN wr_employee_info e ON br.created_by = e.employee_id
|
||||
WHERE br.business_report_id = $1
|
||||
`, [businessReportId])
|
||||
|
||||
if (!report) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 소속 프로젝트 목록
|
||||
const projects = await query(`
|
||||
SELECT project_id, project_name FROM wr_project_info WHERE business_id = $1
|
||||
`, [report.business_id])
|
||||
|
||||
const projectIds = projects.map((p: any) => p.project_id)
|
||||
|
||||
// 해당 주차 실적 목록
|
||||
const tasks = await query(`
|
||||
SELECT
|
||||
t.task_id,
|
||||
t.task_description,
|
||||
t.task_type,
|
||||
t.task_hours,
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
e.employee_id,
|
||||
e.employee_name
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_weekly_report r ON t.report_id = r.report_id
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE t.project_id = ANY($1)
|
||||
AND r.report_year = $2
|
||||
AND r.report_week = $3
|
||||
ORDER BY p.project_name, e.employee_name
|
||||
`, [projectIds, report.report_year, report.report_week])
|
||||
|
||||
return {
|
||||
report: {
|
||||
businessReportId: report.business_report_id,
|
||||
businessId: report.business_id,
|
||||
businessName: report.business_name,
|
||||
reportYear: report.report_year,
|
||||
reportWeek: report.report_week,
|
||||
weekStartDate: report.week_start_date,
|
||||
weekEndDate: report.week_end_date,
|
||||
aiSummary: report.ai_summary,
|
||||
manualSummary: report.manual_summary,
|
||||
status: report.status,
|
||||
createdByName: report.created_by_name,
|
||||
createdAt: report.created_at,
|
||||
updatedAt: report.updated_at
|
||||
},
|
||||
tasks: tasks.map((t: any) => ({
|
||||
taskId: t.task_id,
|
||||
taskDescription: t.task_description,
|
||||
taskType: t.task_type,
|
||||
taskHours: t.task_hours,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
employeeId: t.employee_id,
|
||||
employeeName: t.employee_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
31
backend/api/business-report/[id]/update.put.ts
Normal file
31
backend/api/business-report/[id]/update.put.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 주간보고 수정
|
||||
* PUT /api/business-report/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessReportId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<{ manualSummary: string }>(event)
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT * FROM wr_business_weekly_report WHERE business_report_id = $1
|
||||
`, [businessReportId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (existing.status === 'confirmed') {
|
||||
throw createError({ statusCode: 400, message: '확정된 보고서는 수정할 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_business_weekly_report SET
|
||||
manual_summary = $1,
|
||||
updated_at = NOW()
|
||||
WHERE business_report_id = $2
|
||||
`, [body.manualSummary, businessReportId])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
148
backend/api/business-report/generate.post.ts
Normal file
148
backend/api/business-report/generate.post.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { query, queryOne, insertReturning, execute } from '../../utils/db'
|
||||
import { callOpenAI } from '../../utils/openai'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface GenerateBody {
|
||||
businessId: number
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업 주간보고 취합 생성
|
||||
* POST /api/business-report/generate
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<GenerateBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.businessId || !body.reportYear || !body.reportWeek) {
|
||||
throw createError({ statusCode: 400, message: '필수 파라미터가 누락되었습니다.' })
|
||||
}
|
||||
|
||||
// 기존 보고서 확인
|
||||
const existing = await queryOne(`
|
||||
SELECT * FROM wr_business_weekly_report
|
||||
WHERE business_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [body.businessId, body.reportYear, body.reportWeek])
|
||||
|
||||
// 사업에 속한 프로젝트 목록
|
||||
const projects = await query(`
|
||||
SELECT project_id, project_name FROM wr_project_info WHERE business_id = $1
|
||||
`, [body.businessId])
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '해당 사업에 속한 프로젝트가 없습니다.' })
|
||||
}
|
||||
|
||||
const projectIds = projects.map((p: any) => p.project_id)
|
||||
|
||||
// 해당 주차 주간보고 실적 조회
|
||||
const tasks = await query(`
|
||||
SELECT
|
||||
t.task_description,
|
||||
t.task_type,
|
||||
t.task_hours,
|
||||
p.project_name,
|
||||
e.employee_name
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_weekly_report r ON t.report_id = r.report_id
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE t.project_id = ANY($1)
|
||||
AND r.report_year = $2
|
||||
AND r.report_week = $3
|
||||
ORDER BY p.project_name, e.employee_name
|
||||
`, [projectIds, body.reportYear, body.reportWeek])
|
||||
|
||||
if (tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '해당 주차에 등록된 실적이 없습니다.' })
|
||||
}
|
||||
|
||||
// 프로젝트별로 그룹화
|
||||
const groupedTasks: Record<string, any[]> = {}
|
||||
for (const task of tasks) {
|
||||
const key = task.project_name
|
||||
if (!groupedTasks[key]) groupedTasks[key] = []
|
||||
groupedTasks[key].push(task)
|
||||
}
|
||||
|
||||
// OpenAI 프롬프트 생성
|
||||
let taskText = ''
|
||||
for (const [projectName, projectTasks] of Object.entries(groupedTasks)) {
|
||||
taskText += `\n[${projectName}]\n`
|
||||
for (const t of projectTasks) {
|
||||
taskText += `- ${t.employee_name}: ${t.task_description}\n`
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = `다음은 사업의 주간 실적입니다. 이를 경영진에게 보고하기 위한 간결한 요약문을 작성해주세요.
|
||||
|
||||
${taskText}
|
||||
|
||||
요약 작성 가이드:
|
||||
1. 프로젝트별로 구분하여 작성
|
||||
2. 핵심 성과와 진행 상황 중심
|
||||
3. 한국어로 작성
|
||||
4. 불릿 포인트 형식
|
||||
5. 200자 이내로 간결하게
|
||||
|
||||
JSON 형식으로 응답해주세요:
|
||||
{
|
||||
"summary": "요약 내용"
|
||||
}`
|
||||
|
||||
let aiSummary = ''
|
||||
try {
|
||||
const response = await callOpenAI([
|
||||
{ role: 'system', content: '당신은 프로젝트 관리 전문가입니다. 주간 실적을 간결하게 요약합니다.' },
|
||||
{ role: 'user', content: prompt }
|
||||
], true)
|
||||
|
||||
const parsed = JSON.parse(response)
|
||||
aiSummary = parsed.summary || response
|
||||
} catch (e) {
|
||||
console.error('OpenAI error:', e)
|
||||
aiSummary = '(AI 요약 생성 실패)'
|
||||
}
|
||||
|
||||
let result
|
||||
if (existing) {
|
||||
// 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_business_weekly_report SET
|
||||
ai_summary = $1,
|
||||
updated_at = NOW()
|
||||
WHERE business_report_id = $2
|
||||
`, [aiSummary, existing.business_report_id])
|
||||
result = { ...existing, ai_summary: aiSummary }
|
||||
} else {
|
||||
// 신규 생성
|
||||
result = await insertReturning(`
|
||||
INSERT INTO wr_business_weekly_report (
|
||||
business_id, report_year, report_week, week_start_date, week_end_date,
|
||||
ai_summary, status, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.businessId,
|
||||
body.reportYear,
|
||||
body.reportWeek,
|
||||
body.weekStartDate,
|
||||
body.weekEndDate,
|
||||
aiSummary,
|
||||
userId
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
report: {
|
||||
businessReportId: result.business_report_id,
|
||||
aiSummary: result.ai_summary || aiSummary,
|
||||
status: result.status || 'draft'
|
||||
}
|
||||
}
|
||||
})
|
||||
51
backend/api/business-report/list.get.ts
Normal file
51
backend/api/business-report/list.get.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 주간보고 목록 조회
|
||||
* GET /api/business-report/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const businessId = params.businessId ? Number(params.businessId) : null
|
||||
const year = params.year ? Number(params.year) : new Date().getFullYear()
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
br.*,
|
||||
b.business_name,
|
||||
e.employee_name as created_by_name
|
||||
FROM wr_business_weekly_report br
|
||||
JOIN wr_business b ON br.business_id = b.business_id
|
||||
LEFT JOIN wr_employee_info e ON br.created_by = e.employee_id
|
||||
WHERE br.report_year = $1
|
||||
`
|
||||
const queryParams: any[] = [year]
|
||||
let paramIndex = 2
|
||||
|
||||
if (businessId) {
|
||||
sql += ` AND br.business_id = $${paramIndex++}`
|
||||
queryParams.push(businessId)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY br.report_week DESC, br.business_id'
|
||||
|
||||
const reports = await query(sql, queryParams)
|
||||
|
||||
return {
|
||||
reports: reports.map((r: any) => ({
|
||||
businessReportId: r.business_report_id,
|
||||
businessId: r.business_id,
|
||||
businessName: r.business_name,
|
||||
reportYear: r.report_year,
|
||||
reportWeek: r.report_week,
|
||||
weekStartDate: r.week_start_date,
|
||||
weekEndDate: r.week_end_date,
|
||||
aiSummary: r.ai_summary,
|
||||
manualSummary: r.manual_summary,
|
||||
status: r.status,
|
||||
createdByName: r.created_by_name,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
44
backend/api/business/[id]/delete.delete.ts
Normal file
44
backend/api/business/[id]/delete.delete.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 삭제 (상태를 suspended로 변경)
|
||||
* DELETE /api/business/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!businessId) {
|
||||
throw createError({ statusCode: 400, message: '사업 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT business_id FROM wr_business WHERE business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '사업을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 소속 프로젝트 체크
|
||||
const projectCount = await queryOne(`
|
||||
SELECT COUNT(*) as cnt FROM wr_project_info WHERE business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
if (Number(projectCount?.cnt) > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `소속된 프로젝트가 ${projectCount.cnt}개 있습니다. 먼저 프로젝트를 해제하세요.`
|
||||
})
|
||||
}
|
||||
|
||||
// 완전 삭제 대신 상태 변경
|
||||
await execute(`
|
||||
UPDATE wr_business SET business_status = 'suspended', updated_at = NOW()
|
||||
WHERE business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '사업이 삭제(중단) 처리되었습니다.'
|
||||
}
|
||||
})
|
||||
71
backend/api/business/[id]/detail.get.ts
Normal file
71
backend/api/business/[id]/detail.get.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { query, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 상세 조회
|
||||
* GET /api/business/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!businessId) {
|
||||
throw createError({ statusCode: 400, message: '사업 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const business = await queryOne(`
|
||||
SELECT
|
||||
b.*,
|
||||
e1.employee_name as created_by_name,
|
||||
e2.employee_name as updated_by_name
|
||||
FROM wr_business b
|
||||
LEFT JOIN wr_employee_info e1 ON b.created_by = e1.employee_id
|
||||
LEFT JOIN wr_employee_info e2 ON b.updated_by = e2.employee_id
|
||||
WHERE b.business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
if (!business) {
|
||||
throw createError({ statusCode: 404, message: '사업을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 소속 프로젝트 목록
|
||||
const projects = await query(`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
p.project_code,
|
||||
p.project_type,
|
||||
p.project_status,
|
||||
p.start_date,
|
||||
p.end_date
|
||||
FROM wr_project_info p
|
||||
WHERE p.business_id = $1
|
||||
ORDER BY p.project_name
|
||||
`, [businessId])
|
||||
|
||||
return {
|
||||
business: {
|
||||
businessId: business.business_id,
|
||||
businessName: business.business_name,
|
||||
businessCode: business.business_code,
|
||||
clientName: business.client_name,
|
||||
contractStartDate: business.contract_start_date,
|
||||
contractEndDate: business.contract_end_date,
|
||||
businessStatus: business.business_status,
|
||||
description: business.description,
|
||||
createdBy: business.created_by,
|
||||
createdByName: business.created_by_name,
|
||||
updatedBy: business.updated_by,
|
||||
updatedByName: business.updated_by_name,
|
||||
createdAt: business.created_at,
|
||||
updatedAt: business.updated_at
|
||||
},
|
||||
projects: projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectName: p.project_name,
|
||||
projectCode: p.project_code,
|
||||
projectType: p.project_type,
|
||||
projectStatus: p.project_status,
|
||||
startDate: p.start_date,
|
||||
endDate: p.end_date
|
||||
}))
|
||||
}
|
||||
})
|
||||
68
backend/api/business/[id]/update.put.ts
Normal file
68
backend/api/business/[id]/update.put.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
|
||||
interface UpdateBusinessBody {
|
||||
businessName: string
|
||||
businessCode?: string
|
||||
clientName?: string
|
||||
contractStartDate?: string
|
||||
contractEndDate?: string
|
||||
businessStatus?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업 수정
|
||||
* PUT /api/business/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateBusinessBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!businessId) {
|
||||
throw createError({ statusCode: 400, message: '사업 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
if (!body.businessName) {
|
||||
throw createError({ statusCode: 400, message: '사업명은 필수입니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT business_id FROM wr_business WHERE business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '사업을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_business SET
|
||||
business_name = $1,
|
||||
business_code = $2,
|
||||
client_name = $3,
|
||||
contract_start_date = $4,
|
||||
contract_end_date = $5,
|
||||
business_status = $6,
|
||||
description = $7,
|
||||
updated_at = NOW(),
|
||||
updated_by = $8
|
||||
WHERE business_id = $9
|
||||
`, [
|
||||
body.businessName,
|
||||
body.businessCode || null,
|
||||
body.clientName || null,
|
||||
body.contractStartDate || null,
|
||||
body.contractEndDate || null,
|
||||
body.businessStatus || 'active',
|
||||
body.description || null,
|
||||
userId,
|
||||
businessId
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
businessId,
|
||||
message: '사업이 수정되었습니다.'
|
||||
}
|
||||
})
|
||||
47
backend/api/business/create.post.ts
Normal file
47
backend/api/business/create.post.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { insertReturning } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface CreateBusinessBody {
|
||||
businessName: string
|
||||
businessCode?: string
|
||||
clientName?: string
|
||||
contractStartDate?: string
|
||||
contractEndDate?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업 생성
|
||||
* POST /api/business/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateBusinessBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.businessName) {
|
||||
throw createError({ statusCode: 400, message: '사업명은 필수입니다.' })
|
||||
}
|
||||
|
||||
const business = await insertReturning(`
|
||||
INSERT INTO wr_business (
|
||||
business_name, business_code, client_name,
|
||||
contract_start_date, contract_end_date, description,
|
||||
business_status, created_by, updated_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $7)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.businessName,
|
||||
body.businessCode || null,
|
||||
body.clientName || null,
|
||||
body.contractStartDate || null,
|
||||
body.contractEndDate || null,
|
||||
body.description || null,
|
||||
userId
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
businessId: business.business_id,
|
||||
message: '사업이 등록되었습니다.'
|
||||
}
|
||||
})
|
||||
77
backend/api/business/list.get.ts
Normal file
77
backend/api/business/list.get.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 목록 조회
|
||||
* GET /api/business/list
|
||||
*
|
||||
* Query params:
|
||||
* - status: 상태 필터 (active, completed, suspended)
|
||||
* - businessName: 사업명 검색
|
||||
* - businessCode: 사업코드 검색
|
||||
* - clientName: 발주처 검색
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
|
||||
const status = params.status as string | null
|
||||
const businessName = params.businessName as string | null
|
||||
const businessCode = params.businessCode as string | null
|
||||
const clientName = params.clientName as string | null
|
||||
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (status) {
|
||||
conditions.push(`b.business_status = $${paramIndex++}`)
|
||||
values.push(status)
|
||||
}
|
||||
|
||||
if (businessName) {
|
||||
conditions.push(`b.business_name ILIKE $${paramIndex++}`)
|
||||
values.push(`%${businessName}%`)
|
||||
}
|
||||
|
||||
if (businessCode) {
|
||||
conditions.push(`b.business_code ILIKE $${paramIndex++}`)
|
||||
values.push(`%${businessCode}%`)
|
||||
}
|
||||
|
||||
if (clientName) {
|
||||
conditions.push(`b.client_name ILIKE $${paramIndex++}`)
|
||||
values.push(`%${clientName}%`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
b.*,
|
||||
e.employee_name as created_by_name,
|
||||
(SELECT COUNT(*) FROM wr_project_info WHERE business_id = b.business_id) as project_count
|
||||
FROM wr_business b
|
||||
LEFT JOIN wr_employee_info e ON b.created_by = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY b.created_at DESC
|
||||
`
|
||||
|
||||
const businesses = await query(sql, values)
|
||||
|
||||
return {
|
||||
businesses: businesses.map((b: any) => ({
|
||||
businessId: b.business_id,
|
||||
businessName: b.business_name,
|
||||
businessCode: b.business_code,
|
||||
clientName: b.client_name,
|
||||
contractStartDate: b.contract_start_date,
|
||||
contractEndDate: b.contract_end_date,
|
||||
businessStatus: b.business_status,
|
||||
description: b.description,
|
||||
projectCount: Number(b.project_count),
|
||||
createdBy: b.created_by,
|
||||
createdByName: b.created_by_name,
|
||||
createdAt: b.created_at,
|
||||
updatedAt: b.updated_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
36
backend/api/maintenance/[id]/delete.delete.ts
Normal file
36
backend/api/maintenance/[id]/delete.delete.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 삭제
|
||||
* DELETE /api/maintenance/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const taskId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!taskId) {
|
||||
throw createError({ statusCode: 400, message: '업무 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT task_id, weekly_report_id FROM wr_maintenance_task WHERE task_id = $1
|
||||
`, [taskId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '업무를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 주간보고에 연계된 경우 경고
|
||||
if (existing.weekly_report_id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '주간보고에 연계된 업무입니다. 연계 해제 후 삭제하세요.'
|
||||
})
|
||||
}
|
||||
|
||||
await execute(`DELETE FROM wr_maintenance_task WHERE task_id = $1`, [taskId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '유지보수 업무가 삭제되었습니다.'
|
||||
}
|
||||
})
|
||||
64
backend/api/maintenance/[id]/detail.get.ts
Normal file
64
backend/api/maintenance/[id]/detail.get.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 상세 조회
|
||||
* GET /api/maintenance/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const taskId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!taskId) {
|
||||
throw createError({ statusCode: 400, message: '업무 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const task = await queryOne(`
|
||||
SELECT
|
||||
t.*,
|
||||
p.project_name,
|
||||
p.project_code,
|
||||
e1.employee_name as assignee_name,
|
||||
e2.employee_name as created_by_name,
|
||||
e3.employee_name as updated_by_name
|
||||
FROM wr_maintenance_task t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e1 ON t.assignee_id = e1.employee_id
|
||||
LEFT JOIN wr_employee_info e2 ON t.created_by = e2.employee_id
|
||||
LEFT JOIN wr_employee_info e3 ON t.updated_by = e3.employee_id
|
||||
WHERE t.task_id = $1
|
||||
`, [taskId])
|
||||
|
||||
if (!task) {
|
||||
throw createError({ statusCode: 404, message: '업무를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
return {
|
||||
task: {
|
||||
taskId: task.task_id,
|
||||
projectId: task.project_id,
|
||||
projectName: task.project_name,
|
||||
projectCode: task.project_code,
|
||||
batchId: task.batch_id,
|
||||
requestDate: task.request_date,
|
||||
requestTitle: task.request_title,
|
||||
requestContent: task.request_content,
|
||||
requesterName: task.requester_name,
|
||||
requesterContact: task.requester_contact,
|
||||
taskType: task.task_type,
|
||||
priority: task.priority,
|
||||
status: task.status,
|
||||
assigneeId: task.assignee_id,
|
||||
assigneeName: task.assignee_name,
|
||||
devCompletedAt: task.dev_completed_at,
|
||||
opsCompletedAt: task.ops_completed_at,
|
||||
clientConfirmedAt: task.client_confirmed_at,
|
||||
resolutionContent: task.resolution_content,
|
||||
weeklyReportId: task.weekly_report_id,
|
||||
createdBy: task.created_by,
|
||||
createdByName: task.created_by_name,
|
||||
updatedBy: task.updated_by,
|
||||
updatedByName: task.updated_by_name,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at
|
||||
}
|
||||
}
|
||||
})
|
||||
48
backend/api/maintenance/[id]/status.put.ts
Normal file
48
backend/api/maintenance/[id]/status.put.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
|
||||
interface StatusBody {
|
||||
status: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무 상태 변경
|
||||
* PUT /api/maintenance/[id]/status
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const taskId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<StatusBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!taskId) {
|
||||
throw createError({ statusCode: 400, message: '업무 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const validStatuses = ['PENDING', 'IN_PROGRESS', 'COMPLETED']
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
throw createError({ statusCode: 400, message: '유효하지 않은 상태입니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT task_id FROM wr_maintenance_task WHERE task_id = $1
|
||||
`, [taskId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '업무를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_maintenance_task SET
|
||||
status = $1,
|
||||
updated_at = NOW(),
|
||||
updated_by = $2
|
||||
WHERE task_id = $3
|
||||
`, [body.status, userId, taskId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
status: body.status,
|
||||
message: '상태가 변경되었습니다.'
|
||||
}
|
||||
})
|
||||
85
backend/api/maintenance/[id]/update.put.ts
Normal file
85
backend/api/maintenance/[id]/update.put.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
|
||||
interface UpdateMaintenanceBody {
|
||||
projectId?: number | null
|
||||
requestDate?: string
|
||||
requestTitle?: string
|
||||
requestContent?: string
|
||||
requesterName?: string
|
||||
requesterContact?: string
|
||||
taskType?: string
|
||||
priority?: string
|
||||
status?: string
|
||||
assigneeId?: number | null
|
||||
resolutionContent?: string
|
||||
devCompletedAt?: string | null
|
||||
opsCompletedAt?: string | null
|
||||
clientConfirmedAt?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무 수정
|
||||
* PUT /api/maintenance/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const taskId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateMaintenanceBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!taskId) {
|
||||
throw createError({ statusCode: 400, message: '업무 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT task_id FROM wr_maintenance_task WHERE task_id = $1
|
||||
`, [taskId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '업무를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_maintenance_task SET
|
||||
project_id = $1,
|
||||
request_date = $2,
|
||||
request_title = $3,
|
||||
request_content = $4,
|
||||
requester_name = $5,
|
||||
requester_contact = $6,
|
||||
task_type = $7,
|
||||
priority = $8,
|
||||
status = $9,
|
||||
assignee_id = $10,
|
||||
resolution_content = $11,
|
||||
dev_completed_at = $12,
|
||||
ops_completed_at = $13,
|
||||
client_confirmed_at = $14,
|
||||
updated_at = NOW(),
|
||||
updated_by = $15
|
||||
WHERE task_id = $16
|
||||
`, [
|
||||
body.projectId ?? null,
|
||||
body.requestDate,
|
||||
body.requestTitle,
|
||||
body.requestContent || null,
|
||||
body.requesterName || null,
|
||||
body.requesterContact || null,
|
||||
body.taskType || 'GENERAL',
|
||||
body.priority || 'MEDIUM',
|
||||
body.status || 'PENDING',
|
||||
body.assigneeId ?? null,
|
||||
body.resolutionContent || null,
|
||||
body.devCompletedAt || null,
|
||||
body.opsCompletedAt || null,
|
||||
body.clientConfirmedAt || null,
|
||||
userId,
|
||||
taskId
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
message: '유지보수 업무가 수정되었습니다.'
|
||||
}
|
||||
})
|
||||
84
backend/api/maintenance/bulk-create.post.ts
Normal file
84
backend/api/maintenance/bulk-create.post.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { insertReturning, query } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface TaskItem {
|
||||
requestDate: string | null
|
||||
requestTitle: string
|
||||
requestContent: string | null
|
||||
requesterName: string | null
|
||||
requesterContact: string | null
|
||||
taskType: string
|
||||
priority: string
|
||||
resolutionContent: string | null
|
||||
isDuplicate?: boolean
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
interface BulkCreateBody {
|
||||
projectId: number
|
||||
batchId: number
|
||||
tasks: TaskItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무 일괄 등록
|
||||
* POST /api/maintenance/bulk-create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<BulkCreateBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.projectId) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트를 선택해주세요.' })
|
||||
}
|
||||
|
||||
if (!body.tasks || body.tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록할 업무가 없습니다.' })
|
||||
}
|
||||
|
||||
// 선택된 항목만 필터 (selected가 false가 아닌 것)
|
||||
const tasksToInsert = body.tasks.filter(t => t.selected !== false && !t.isDuplicate)
|
||||
|
||||
if (tasksToInsert.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록할 업무가 없습니다. (모두 제외되었거나 중복)' })
|
||||
}
|
||||
|
||||
const inserted: number[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (const task of tasksToInsert) {
|
||||
try {
|
||||
const result = await insertReturning(`
|
||||
INSERT INTO wr_maintenance_task (
|
||||
project_id, batch_id, request_date, request_title, request_content,
|
||||
requester_name, requester_contact, task_type, priority, status,
|
||||
resolution_content, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING', $10, $11)
|
||||
RETURNING task_id
|
||||
`, [
|
||||
body.projectId,
|
||||
body.batchId,
|
||||
task.requestDate || null,
|
||||
task.requestTitle,
|
||||
task.requestContent || null,
|
||||
task.requesterName || null,
|
||||
task.requesterContact || null,
|
||||
task.taskType || 'other',
|
||||
task.priority || 'medium',
|
||||
task.resolutionContent || null,
|
||||
userId
|
||||
])
|
||||
inserted.push(result.task_id)
|
||||
} catch (e: any) {
|
||||
errors.push(`${task.requestTitle}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
insertedCount: inserted.length,
|
||||
errorCount: errors.length,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
taskIds: inserted
|
||||
}
|
||||
})
|
||||
57
backend/api/maintenance/create.post.ts
Normal file
57
backend/api/maintenance/create.post.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { insertReturning } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface CreateMaintenanceBody {
|
||||
projectId?: number
|
||||
requestDate: string
|
||||
requestTitle: string
|
||||
requestContent?: string
|
||||
requesterName?: string
|
||||
requesterContact?: string
|
||||
taskType?: string
|
||||
priority?: string
|
||||
assigneeId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무 생성
|
||||
* POST /api/maintenance/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateMaintenanceBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.requestTitle) {
|
||||
throw createError({ statusCode: 400, message: '제목은 필수입니다.' })
|
||||
}
|
||||
|
||||
if (!body.requestDate) {
|
||||
throw createError({ statusCode: 400, message: '요청일은 필수입니다.' })
|
||||
}
|
||||
|
||||
const task = await insertReturning(`
|
||||
INSERT INTO wr_maintenance_task (
|
||||
project_id, request_date, request_title, request_content,
|
||||
requester_name, requester_contact, task_type, priority,
|
||||
assignee_id, status, created_by, updated_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING', $10, $10)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.projectId || null,
|
||||
body.requestDate,
|
||||
body.requestTitle,
|
||||
body.requestContent || null,
|
||||
body.requesterName || null,
|
||||
body.requesterContact || null,
|
||||
body.taskType || 'GENERAL',
|
||||
body.priority || 'MEDIUM',
|
||||
body.assigneeId || null,
|
||||
userId
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId: task.task_id,
|
||||
message: '유지보수 업무가 등록되었습니다.'
|
||||
}
|
||||
})
|
||||
114
backend/api/maintenance/list.get.ts
Normal file
114
backend/api/maintenance/list.get.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 목록 조회
|
||||
* GET /api/maintenance/list
|
||||
*
|
||||
* Query params:
|
||||
* - projectId: 프로젝트 ID
|
||||
* - status: 상태 (PENDING, IN_PROGRESS, COMPLETED)
|
||||
* - priority: 우선순위 (HIGH, MEDIUM, LOW)
|
||||
* - keyword: 검색어 (제목, 내용, 요청자)
|
||||
* - startDate, endDate: 요청일 범위
|
||||
* - page, pageSize: 페이지네이션
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const status = params.status as string | null
|
||||
const priority = params.priority as string | null
|
||||
const keyword = params.keyword as string | null
|
||||
const startDate = params.startDate as string | null
|
||||
const endDate = params.endDate as string | null
|
||||
const page = Number(params.page) || 1
|
||||
const pageSize = Number(params.pageSize) || 20
|
||||
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`t.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push(`t.status = $${paramIndex++}`)
|
||||
values.push(status)
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
conditions.push(`t.priority = $${paramIndex++}`)
|
||||
values.push(priority)
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(t.request_title ILIKE $${paramIndex} OR t.request_content ILIKE $${paramIndex} OR t.requester_name ILIKE $${paramIndex})`)
|
||||
values.push(`%${keyword}%`)
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`t.request_date >= $${paramIndex++}`)
|
||||
values.push(startDate)
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(`t.request_date <= $${paramIndex++}`)
|
||||
values.push(endDate)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
// 전체 카운트
|
||||
const countSql = `SELECT COUNT(*) as total FROM wr_maintenance_task t ${whereClause}`
|
||||
const countResult = await query(countSql, values)
|
||||
const total = Number(countResult[0]?.total || 0)
|
||||
|
||||
// 목록 조회
|
||||
const offset = (page - 1) * pageSize
|
||||
const listSql = `
|
||||
SELECT
|
||||
t.*,
|
||||
p.project_name,
|
||||
e.employee_name as assignee_name
|
||||
FROM wr_maintenance_task t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e ON t.assignee_id = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY t.request_date DESC, t.task_id DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||
`
|
||||
values.push(pageSize, offset)
|
||||
|
||||
const tasks = await query(listSql, values)
|
||||
|
||||
return {
|
||||
tasks: tasks.map((t: any) => ({
|
||||
taskId: t.task_id,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
requestDate: t.request_date,
|
||||
requestTitle: t.request_title,
|
||||
requestContent: t.request_content,
|
||||
requesterName: t.requester_name,
|
||||
requesterContact: t.requester_contact,
|
||||
taskType: t.task_type,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
assigneeId: t.assignee_id,
|
||||
assigneeName: t.assignee_name,
|
||||
devCompletedAt: t.dev_completed_at,
|
||||
opsCompletedAt: t.ops_completed_at,
|
||||
clientConfirmedAt: t.client_confirmed_at,
|
||||
createdAt: t.created_at
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
175
backend/api/maintenance/upload.post.ts
Normal file
175
backend/api/maintenance/upload.post.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { insertReturning, query, queryOne } from '../../utils/db'
|
||||
import { callOpenAI } from '../../utils/openai'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 엑셀/CSV 업로드 및 AI 파싱
|
||||
* POST /api/maintenance/upload
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
// multipart/form-data 처리
|
||||
const formData = await readMultipartFormData(event)
|
||||
if (!formData || formData.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '파일을 업로드해주세요.' })
|
||||
}
|
||||
|
||||
const fileField = formData.find(f => f.name === 'file')
|
||||
const projectIdField = formData.find(f => f.name === 'projectId')
|
||||
|
||||
if (!fileField || !fileField.data) {
|
||||
throw createError({ statusCode: 400, message: '파일이 필요합니다.' })
|
||||
}
|
||||
|
||||
const projectId = projectIdField?.data ? Number(projectIdField.data.toString()) : null
|
||||
|
||||
// 파일 확장자 확인
|
||||
const filename = fileField.filename || ''
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!['xlsx', 'xls', 'csv'].includes(ext || '')) {
|
||||
throw createError({ statusCode: 400, message: '엑셀(.xlsx, .xls) 또는 CSV 파일만 지원합니다.' })
|
||||
}
|
||||
|
||||
// SheetJS로 파싱
|
||||
let rows: any[] = []
|
||||
try {
|
||||
const workbook = XLSX.read(fileField.data, { type: 'buffer' })
|
||||
const sheetName = workbook.SheetNames[0]
|
||||
const sheet = workbook.Sheets[sheetName]
|
||||
rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' })
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 400, message: '파일 파싱에 실패했습니다.' })
|
||||
}
|
||||
|
||||
if (rows.length < 2) {
|
||||
throw createError({ statusCode: 400, message: '데이터가 없습니다. (헤더 + 최소 1행)' })
|
||||
}
|
||||
|
||||
// 헤더와 데이터 분리
|
||||
const headers = rows[0] as string[]
|
||||
const dataRows = rows.slice(1).filter((r: any[]) => r.some(cell => cell !== ''))
|
||||
|
||||
if (dataRows.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '데이터 행이 없습니다.' })
|
||||
}
|
||||
|
||||
// OpenAI로 컬럼 매핑 분석
|
||||
const prompt = `다음은 유지보수 업무 목록 엑셀의 헤더입니다:
|
||||
${JSON.stringify(headers)}
|
||||
|
||||
그리고 샘플 데이터 3행입니다:
|
||||
${JSON.stringify(dataRows.slice(0, 3))}
|
||||
|
||||
각 컬럼이 다음 필드 중 어느 것에 해당하는지 매핑해주세요:
|
||||
- request_date: 요청일 (날짜)
|
||||
- request_title: 요청 제목/건명
|
||||
- request_content: 요청 내용/상세
|
||||
- requester_name: 요청자 이름
|
||||
- requester_contact: 요청자 연락처/이메일/전화
|
||||
- task_type: 유형 (bug/feature/inquiry/other)
|
||||
- priority: 우선순위 (high/medium/low)
|
||||
- resolution_content: 조치 내용/처리 결과
|
||||
|
||||
JSON 형식으로 응답 (인덱스 기반):
|
||||
{
|
||||
"mapping": {
|
||||
"request_date": 0,
|
||||
"request_title": 1,
|
||||
...
|
||||
},
|
||||
"confidence": 0.9
|
||||
}`
|
||||
|
||||
let mapping: Record<string, number> = {}
|
||||
try {
|
||||
const response = await callOpenAI([
|
||||
{ role: 'system', content: '엑셀 컬럼 매핑 전문가입니다. 정확하게 필드를 매핑합니다.' },
|
||||
{ role: 'user', content: prompt }
|
||||
], true)
|
||||
const parsed = JSON.parse(response)
|
||||
mapping = parsed.mapping || {}
|
||||
} catch (e) {
|
||||
console.error('OpenAI mapping error:', e)
|
||||
// 기본 매핑 시도 (순서대로)
|
||||
mapping = { request_date: 0, request_title: 1, request_content: 2 }
|
||||
}
|
||||
|
||||
// 배치 ID 생성
|
||||
const batchResult = await queryOne<{ nextval: string }>(`SELECT nextval('wr_maintenance_batch_seq')`)
|
||||
const batchId = Number(batchResult?.nextval || Date.now())
|
||||
|
||||
// 데이터 변환
|
||||
const parsedTasks = dataRows.map((row: any[], idx: number) => {
|
||||
const getValue = (field: string) => {
|
||||
const colIdx = mapping[field]
|
||||
if (colIdx === undefined || colIdx === null) return null
|
||||
return row[colIdx]?.toString().trim() || null
|
||||
}
|
||||
|
||||
// 날짜 파싱
|
||||
let requestDate = getValue('request_date')
|
||||
if (requestDate) {
|
||||
// 엑셀 시리얼 넘버 처리
|
||||
if (!isNaN(Number(requestDate))) {
|
||||
const excelDate = XLSX.SSF.parse_date_code(Number(requestDate))
|
||||
if (excelDate) {
|
||||
requestDate = `${excelDate.y}-${String(excelDate.m).padStart(2,'0')}-${String(excelDate.d).padStart(2,'0')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 유형 정규화
|
||||
let taskType = getValue('task_type')?.toLowerCase() || 'other'
|
||||
if (taskType.includes('버그') || taskType.includes('오류') || taskType.includes('bug')) taskType = 'bug'
|
||||
else if (taskType.includes('기능') || taskType.includes('개선') || taskType.includes('feature')) taskType = 'feature'
|
||||
else if (taskType.includes('문의') || taskType.includes('inquiry')) taskType = 'inquiry'
|
||||
else taskType = 'other'
|
||||
|
||||
// 우선순위 정규화
|
||||
let priority = getValue('priority')?.toLowerCase() || 'medium'
|
||||
if (priority.includes('높') || priority.includes('긴급') || priority.includes('high')) priority = 'high'
|
||||
else if (priority.includes('낮') || priority.includes('low')) priority = 'low'
|
||||
else priority = 'medium'
|
||||
|
||||
return {
|
||||
rowIndex: idx + 2, // 1-based + 헤더
|
||||
requestDate,
|
||||
requestTitle: getValue('request_title') || `업무 ${idx + 1}`,
|
||||
requestContent: getValue('request_content'),
|
||||
requesterName: getValue('requester_name'),
|
||||
requesterContact: getValue('requester_contact'),
|
||||
taskType,
|
||||
priority,
|
||||
resolutionContent: getValue('resolution_content'),
|
||||
isDuplicate: false
|
||||
}
|
||||
})
|
||||
|
||||
// 중복 감지 (같은 제목 + 같은 날짜)
|
||||
if (projectId) {
|
||||
for (const task of parsedTasks) {
|
||||
if (task.requestTitle && task.requestDate) {
|
||||
const dup = await queryOne(`
|
||||
SELECT task_id FROM wr_maintenance_task
|
||||
WHERE project_id = $1 AND request_title = $2 AND request_date = $3
|
||||
`, [projectId, task.requestTitle, task.requestDate])
|
||||
if (dup) {
|
||||
task.isDuplicate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
batchId,
|
||||
filename,
|
||||
totalRows: dataRows.length,
|
||||
mapping,
|
||||
headers,
|
||||
tasks: parsedTasks
|
||||
}
|
||||
})
|
||||
30
backend/api/meeting/[id]/delete.delete.ts
Normal file
30
backend/api/meeting/[id]/delete.delete.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 회의록 삭제
|
||||
* DELETE /api/meeting/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const meetingId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!meetingId) {
|
||||
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 회의록 존재 확인
|
||||
const existing = await queryOne(`
|
||||
SELECT meeting_id FROM wr_meeting WHERE meeting_id = $1
|
||||
`, [meetingId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// CASCADE 설정으로 참석자, 안건도 함께 삭제됨
|
||||
await execute(`DELETE FROM wr_meeting WHERE meeting_id = $1`, [meetingId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '회의록이 삭제되었습니다.'
|
||||
}
|
||||
})
|
||||
96
backend/api/meeting/[id]/detail.get.ts
Normal file
96
backend/api/meeting/[id]/detail.get.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { query, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 회의록 상세 조회
|
||||
* GET /api/meeting/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const meetingId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!meetingId) {
|
||||
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 회의록 기본 정보
|
||||
const meeting = await queryOne(`
|
||||
SELECT
|
||||
m.*,
|
||||
p.project_name,
|
||||
e.employee_name as author_name,
|
||||
e.employee_email as author_email
|
||||
FROM wr_meeting m
|
||||
LEFT JOIN wr_project_info p ON m.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e ON m.author_id = e.employee_id
|
||||
WHERE m.meeting_id = $1
|
||||
`, [meetingId])
|
||||
|
||||
if (!meeting) {
|
||||
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 참석자 목록
|
||||
const attendees = await query(`
|
||||
SELECT
|
||||
a.attendee_id,
|
||||
a.employee_id,
|
||||
e.employee_name,
|
||||
e.employee_email,
|
||||
e.company,
|
||||
a.external_name,
|
||||
a.external_company
|
||||
FROM wr_meeting_attendee a
|
||||
LEFT JOIN wr_employee_info e ON a.employee_id = e.employee_id
|
||||
WHERE a.meeting_id = $1
|
||||
ORDER BY a.attendee_id
|
||||
`, [meetingId])
|
||||
|
||||
// 안건 목록 (AI 분석 결과)
|
||||
const agendas = await query(`
|
||||
SELECT *
|
||||
FROM wr_meeting_agenda
|
||||
WHERE meeting_id = $1
|
||||
ORDER BY agenda_no
|
||||
`, [meetingId])
|
||||
|
||||
return {
|
||||
meeting: {
|
||||
meetingId: meeting.meeting_id,
|
||||
meetingTitle: meeting.meeting_title,
|
||||
meetingType: meeting.meeting_type,
|
||||
projectId: meeting.project_id,
|
||||
projectName: meeting.project_name,
|
||||
meetingDate: meeting.meeting_date,
|
||||
startTime: meeting.start_time,
|
||||
endTime: meeting.end_time,
|
||||
location: meeting.location,
|
||||
rawContent: meeting.raw_content,
|
||||
aiSummary: meeting.ai_summary,
|
||||
aiStatus: meeting.ai_status,
|
||||
aiProcessedAt: meeting.ai_processed_at,
|
||||
aiConfirmedAt: meeting.ai_confirmed_at,
|
||||
authorId: meeting.author_id,
|
||||
authorName: meeting.author_name,
|
||||
authorEmail: meeting.author_email,
|
||||
createdAt: meeting.created_at,
|
||||
updatedAt: meeting.updated_at
|
||||
},
|
||||
attendees: attendees.map((a: any) => ({
|
||||
attendeeId: a.attendee_id,
|
||||
employeeId: a.employee_id,
|
||||
employeeName: a.employee_name,
|
||||
employeeEmail: a.employee_email,
|
||||
company: a.company,
|
||||
externalName: a.external_name,
|
||||
externalCompany: a.external_company,
|
||||
isExternal: !a.employee_id
|
||||
})),
|
||||
agendas: agendas.map((a: any) => ({
|
||||
agendaId: a.agenda_id,
|
||||
agendaNo: a.agenda_no,
|
||||
agendaTitle: a.agenda_title,
|
||||
agendaContent: a.agenda_content,
|
||||
decisionStatus: a.decision_status,
|
||||
decisionContent: a.decision_content
|
||||
}))
|
||||
}
|
||||
})
|
||||
107
backend/api/meeting/[id]/update.put.ts
Normal file
107
backend/api/meeting/[id]/update.put.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../utils/user'
|
||||
|
||||
interface Attendee {
|
||||
employeeId?: number
|
||||
externalName?: string
|
||||
externalCompany?: string
|
||||
}
|
||||
|
||||
interface UpdateMeetingBody {
|
||||
meetingTitle: string
|
||||
meetingType: 'PROJECT' | 'INTERNAL'
|
||||
projectId?: number
|
||||
meetingDate: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
location?: string
|
||||
rawContent?: string
|
||||
attendees?: Attendee[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록 수정
|
||||
* PUT /api/meeting/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const meetingId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateMeetingBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
|
||||
if (!meetingId) {
|
||||
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 회의록 존재 확인
|
||||
const existing = await queryOne(`
|
||||
SELECT meeting_id FROM wr_meeting WHERE meeting_id = $1
|
||||
`, [meetingId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 필수값 검증
|
||||
if (!body.meetingTitle) {
|
||||
throw createError({ statusCode: 400, message: '회의 제목은 필수입니다.' })
|
||||
}
|
||||
if (body.meetingType === 'PROJECT' && !body.projectId) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트 회의는 프로젝트 선택이 필수입니다.' })
|
||||
}
|
||||
|
||||
// 회의록 UPDATE
|
||||
await execute(`
|
||||
UPDATE wr_meeting SET
|
||||
meeting_title = $1,
|
||||
meeting_type = $2,
|
||||
project_id = $3,
|
||||
meeting_date = $4,
|
||||
start_time = $5,
|
||||
end_time = $6,
|
||||
location = $7,
|
||||
raw_content = $8,
|
||||
updated_at = NOW(),
|
||||
updated_ip = $9,
|
||||
updated_email = $10
|
||||
WHERE meeting_id = $11
|
||||
`, [
|
||||
body.meetingTitle,
|
||||
body.meetingType,
|
||||
body.meetingType === 'PROJECT' ? body.projectId : null,
|
||||
body.meetingDate,
|
||||
body.startTime || null,
|
||||
body.endTime || null,
|
||||
body.location || null,
|
||||
body.rawContent || null,
|
||||
clientIp,
|
||||
userEmail,
|
||||
meetingId
|
||||
])
|
||||
|
||||
// 참석자 갱신 (기존 삭제 후 새로 INSERT)
|
||||
await execute(`DELETE FROM wr_meeting_attendee WHERE meeting_id = $1`, [meetingId])
|
||||
|
||||
if (body.attendees && body.attendees.length > 0) {
|
||||
for (const att of body.attendees) {
|
||||
if (att.employeeId) {
|
||||
await execute(`
|
||||
INSERT INTO wr_meeting_attendee (meeting_id, employee_id)
|
||||
VALUES ($1, $2)
|
||||
`, [meetingId, att.employeeId])
|
||||
} else if (att.externalName) {
|
||||
await execute(`
|
||||
INSERT INTO wr_meeting_attendee (meeting_id, external_name, external_company)
|
||||
VALUES ($1, $2, $3)
|
||||
`, [meetingId, att.externalName, att.externalCompany || null])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
meetingId,
|
||||
message: '회의록이 수정되었습니다.'
|
||||
}
|
||||
})
|
||||
92
backend/api/meeting/create.post.ts
Normal file
92
backend/api/meeting/create.post.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { insertReturning, query, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { getCurrentUserEmail, getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface Attendee {
|
||||
employeeId?: number
|
||||
externalName?: string
|
||||
externalCompany?: string
|
||||
}
|
||||
|
||||
interface CreateMeetingBody {
|
||||
meetingTitle: string
|
||||
meetingType: 'PROJECT' | 'INTERNAL'
|
||||
projectId?: number
|
||||
meetingDate: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
location?: string
|
||||
rawContent?: string
|
||||
attendees?: Attendee[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록 작성
|
||||
* POST /api/meeting/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateMeetingBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
// 필수값 검증
|
||||
if (!body.meetingTitle) {
|
||||
throw createError({ statusCode: 400, message: '회의 제목은 필수입니다.' })
|
||||
}
|
||||
if (!body.meetingType) {
|
||||
throw createError({ statusCode: 400, message: '회의 유형은 필수입니다.' })
|
||||
}
|
||||
if (!body.meetingDate) {
|
||||
throw createError({ statusCode: 400, message: '회의 일자는 필수입니다.' })
|
||||
}
|
||||
if (body.meetingType === 'PROJECT' && !body.projectId) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트 회의는 프로젝트 선택이 필수입니다.' })
|
||||
}
|
||||
|
||||
// 회의록 INSERT
|
||||
const meeting = await insertReturning(`
|
||||
INSERT INTO wr_meeting (
|
||||
meeting_title, meeting_type, project_id,
|
||||
meeting_date, start_time, end_time, location,
|
||||
raw_content, ai_status, author_id,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'NONE', $9, $10, $11, $10, $11)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.meetingTitle,
|
||||
body.meetingType,
|
||||
body.meetingType === 'PROJECT' ? body.projectId : null,
|
||||
body.meetingDate,
|
||||
body.startTime || null,
|
||||
body.endTime || null,
|
||||
body.location || null,
|
||||
body.rawContent || null,
|
||||
userId,
|
||||
clientIp,
|
||||
userEmail
|
||||
])
|
||||
|
||||
// 참석자 INSERT
|
||||
if (body.attendees && body.attendees.length > 0) {
|
||||
for (const att of body.attendees) {
|
||||
if (att.employeeId) {
|
||||
await execute(`
|
||||
INSERT INTO wr_meeting_attendee (meeting_id, employee_id)
|
||||
VALUES ($1, $2)
|
||||
`, [meeting.meeting_id, att.employeeId])
|
||||
} else if (att.externalName) {
|
||||
await execute(`
|
||||
INSERT INTO wr_meeting_attendee (meeting_id, external_name, external_company)
|
||||
VALUES ($1, $2, $3)
|
||||
`, [meeting.meeting_id, att.externalName, att.externalCompany || null])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
meetingId: meeting.meeting_id,
|
||||
message: '회의록이 등록되었습니다.'
|
||||
}
|
||||
})
|
||||
122
backend/api/meeting/list.get.ts
Normal file
122
backend/api/meeting/list.get.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 회의록 목록 조회
|
||||
* GET /api/meeting/list
|
||||
*
|
||||
* Query params:
|
||||
* - projectId: 프로젝트 필터 (선택)
|
||||
* - meetingType: PROJECT | INTERNAL (선택)
|
||||
* - startDate: 시작일 (선택)
|
||||
* - endDate: 종료일 (선택)
|
||||
* - keyword: 검색어 (선택)
|
||||
* - page: 페이지 번호 (기본 1)
|
||||
* - pageSize: 페이지 크기 (기본 20)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const meetingType = params.meetingType as string | null
|
||||
const startDate = params.startDate as string | null
|
||||
const endDate = params.endDate as string | null
|
||||
const keyword = params.keyword as string | null
|
||||
const page = Number(params.page) || 1
|
||||
const pageSize = Number(params.pageSize) || 20
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
// WHERE 조건 구성
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`m.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (meetingType) {
|
||||
conditions.push(`m.meeting_type = $${paramIndex++}`)
|
||||
values.push(meetingType)
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`m.meeting_date >= $${paramIndex++}`)
|
||||
values.push(startDate)
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(`m.meeting_date <= $${paramIndex++}`)
|
||||
values.push(endDate)
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(m.meeting_title ILIKE $${paramIndex} OR m.raw_content ILIKE $${paramIndex})`)
|
||||
values.push(`%${keyword}%`)
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
// 전체 건수 조회
|
||||
const countSql = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM wr_meeting m
|
||||
${whereClause}
|
||||
`
|
||||
const countResult = await query(countSql, values)
|
||||
const total = Number(countResult[0]?.total || 0)
|
||||
|
||||
// 목록 조회
|
||||
const listSql = `
|
||||
SELECT
|
||||
m.meeting_id,
|
||||
m.meeting_title,
|
||||
m.meeting_type,
|
||||
m.project_id,
|
||||
p.project_name,
|
||||
m.meeting_date,
|
||||
m.start_time,
|
||||
m.end_time,
|
||||
m.location,
|
||||
m.ai_status,
|
||||
m.author_id,
|
||||
e.employee_name as author_name,
|
||||
m.created_at,
|
||||
(SELECT COUNT(*) FROM wr_meeting_attendee WHERE meeting_id = m.meeting_id) as attendee_count
|
||||
FROM wr_meeting m
|
||||
LEFT JOIN wr_project_info p ON m.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e ON m.author_id = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY m.meeting_date DESC, m.created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||
`
|
||||
values.push(pageSize, offset)
|
||||
|
||||
const meetings = await query(listSql, values)
|
||||
|
||||
return {
|
||||
meetings: meetings.map((m: any) => ({
|
||||
meetingId: m.meeting_id,
|
||||
meetingTitle: m.meeting_title,
|
||||
meetingType: m.meeting_type,
|
||||
projectId: m.project_id,
|
||||
projectName: m.project_name,
|
||||
meetingDate: m.meeting_date,
|
||||
startTime: m.start_time,
|
||||
endTime: m.end_time,
|
||||
location: m.location,
|
||||
aiStatus: m.ai_status,
|
||||
authorId: m.author_id,
|
||||
authorName: m.author_name,
|
||||
attendeeCount: Number(m.attendee_count),
|
||||
createdAt: m.created_at
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -8,7 +8,10 @@ export default defineEventHandler(async (event) => {
|
||||
const projectId = getRouterParam(event, 'id')
|
||||
|
||||
const project = await queryOne<any>(`
|
||||
SELECT * FROM wr_project_info WHERE project_id = $1
|
||||
SELECT p.*, b.business_name, b.business_code
|
||||
FROM wr_project_info p
|
||||
LEFT JOIN wr_business b ON p.business_id = b.business_id
|
||||
WHERE p.project_id = $1
|
||||
`, [projectId])
|
||||
|
||||
if (!project) {
|
||||
@@ -35,6 +38,9 @@ export default defineEventHandler(async (event) => {
|
||||
endDate: project.end_date,
|
||||
contractAmount: project.contract_amount,
|
||||
projectStatus: project.project_status,
|
||||
businessId: project.business_id,
|
||||
businessName: project.business_name,
|
||||
businessCode: project.business_code,
|
||||
createdAt: project.created_at,
|
||||
updatedAt: project.updated_at,
|
||||
currentPm: pm ? { employeeId: pm.employee_id, employeeName: pm.employee_name } : null,
|
||||
|
||||
@@ -11,6 +11,7 @@ interface UpdateProjectBody {
|
||||
endDate?: string
|
||||
contractAmount?: number
|
||||
projectStatus?: string
|
||||
businessId?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,10 +47,11 @@ export default defineEventHandler(async (event) => {
|
||||
end_date = $6,
|
||||
contract_amount = $7,
|
||||
project_status = $8,
|
||||
business_id = $9,
|
||||
updated_at = NOW(),
|
||||
updated_ip = $9,
|
||||
updated_email = $10
|
||||
WHERE project_id = $11
|
||||
updated_ip = $10,
|
||||
updated_email = $11
|
||||
WHERE project_id = $12
|
||||
`, [
|
||||
body.projectName ?? existing.project_name,
|
||||
body.projectType ?? existing.project_type ?? 'SI',
|
||||
@@ -59,6 +61,7 @@ export default defineEventHandler(async (event) => {
|
||||
body.endDate ?? existing.end_date,
|
||||
body.contractAmount ?? existing.contract_amount,
|
||||
body.projectStatus ?? existing.project_status,
|
||||
body.businessId !== undefined ? body.businessId : existing.business_id,
|
||||
clientIp,
|
||||
userEmail,
|
||||
projectId
|
||||
|
||||
@@ -10,6 +10,7 @@ interface CreateProjectBody {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
contractAmount?: number
|
||||
businessId?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,9 +63,9 @@ export default defineEventHandler(async (event) => {
|
||||
const project = await insertReturning(`
|
||||
INSERT INTO wr_project_info (
|
||||
project_code, project_name, project_type, client_name, project_description,
|
||||
start_date, end_date, contract_amount,
|
||||
start_date, end_date, contract_amount, business_id,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $9, $10)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $10, $11)
|
||||
RETURNING *
|
||||
`, [
|
||||
projectCode,
|
||||
@@ -75,6 +76,7 @@ export default defineEventHandler(async (event) => {
|
||||
body.startDate || null,
|
||||
body.endDate || null,
|
||||
body.contractAmount || null,
|
||||
body.businessId || null,
|
||||
clientIp,
|
||||
userEmail
|
||||
])
|
||||
@@ -85,7 +87,8 @@ export default defineEventHandler(async (event) => {
|
||||
projectId: project.project_id,
|
||||
projectCode: project.project_code,
|
||||
projectName: project.project_name,
|
||||
projectType: project.project_type
|
||||
projectType: project.project_type,
|
||||
businessId: project.business_id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,9 +7,11 @@ import { query } from '../../utils/db'
|
||||
export default defineEventHandler(async (event) => {
|
||||
const queryParams = getQuery(event)
|
||||
const status = queryParams.status as string || null
|
||||
const businessId = queryParams.businessId ? Number(queryParams.businessId) : null
|
||||
|
||||
let sql = `
|
||||
SELECT p.*,
|
||||
b.business_name,
|
||||
(SELECT employee_name FROM wr_employee_info e
|
||||
JOIN wr_project_manager_history pm ON e.employee_id = pm.employee_id
|
||||
WHERE pm.project_id = p.project_id AND pm.role_type = 'PM'
|
||||
@@ -21,14 +23,27 @@ export default defineEventHandler(async (event) => {
|
||||
AND (pm.end_date IS NULL OR pm.end_date >= CURRENT_DATE)
|
||||
LIMIT 1) as pl_name
|
||||
FROM wr_project_info p
|
||||
LEFT JOIN wr_business b ON p.business_id = b.business_id
|
||||
`
|
||||
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (status) {
|
||||
sql += ' WHERE p.project_status = $1'
|
||||
conditions.push(`p.project_status = $${paramIndex++}`)
|
||||
params.push(status)
|
||||
}
|
||||
|
||||
if (businessId) {
|
||||
conditions.push(`p.business_id = $${paramIndex++}`)
|
||||
params.push(businessId)
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += ' WHERE ' + conditions.join(' AND ')
|
||||
}
|
||||
|
||||
sql += ' ORDER BY p.created_at DESC'
|
||||
|
||||
const projects = await query(sql, params)
|
||||
@@ -45,6 +60,8 @@ export default defineEventHandler(async (event) => {
|
||||
endDate: p.end_date,
|
||||
contractAmount: p.contract_amount,
|
||||
projectStatus: p.project_status,
|
||||
businessId: p.business_id,
|
||||
businessName: p.business_name,
|
||||
pmName: p.pm_name,
|
||||
plName: p.pl_name,
|
||||
createdAt: p.created_at
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { H3Event } from 'h3'
|
||||
import { queryOne } from './db'
|
||||
import { getAuthenticatedUserId } from './session'
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 ID 조회
|
||||
*/
|
||||
export async function getCurrentUserId(event: H3Event): Promise<number | null> {
|
||||
return await getAuthenticatedUserId(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 이메일 조회
|
||||
*/
|
||||
|
||||
746
claude_temp/00_마스터_작업계획서.md
Normal file
746
claude_temp/00_마스터_작업계획서.md
Normal file
@@ -0,0 +1,746 @@
|
||||
# 주간보고 시스템 마스터 작업계획서
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 총 예상 기간: 6~8주
|
||||
> 총 Phase 수: 30개 (통합 DB 포함)
|
||||
> 총 테이블 수: 16개
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
### 1.1 목표
|
||||
주간보고 시스템에 7개 신규 기능을 추가하여 업무 효율성 향상
|
||||
|
||||
### 1.2 작업 목록
|
||||
|
||||
| # | 작업명 | 예상 기간 | Phase | 난이도 | 의존성 |
|
||||
|:-:|--------|:---------:|:-----:|:------:|:------:|
|
||||
| 01 | 회의록 + TODO | 5~7일 | 4 | ⭐⭐ | 없음 |
|
||||
| 02 | 사업-프로젝트 계층 | 3~5일 | 4 | ⭐⭐ | 없음 |
|
||||
| 03 | 유지보수 업무관리 | 5~7일 | 4 | ⭐⭐⭐ | 없음 |
|
||||
| 04 | Gmail OAuth | 5~7일 | 5 | ⭐⭐⭐ | 없음 |
|
||||
| 05 | Synology SSO | 2~3일 | 2 | ⭐ | **04 완료** |
|
||||
| 06 | 구글 그룹 연동 | 1~2주 | 4 | ⭐⭐⭐ | **04 완료** |
|
||||
| 07 | SVN/Git 연동 | 2~3주 | 6 | ⭐⭐⭐ | 02 권장 |
|
||||
|
||||
### 1.3 의존성 다이어그램
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 독립 실행 가능 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │01 회의록│ │02 사업 │ │03 유지 │ │04 OAuth│ │
|
||||
│ └────────┘ └───┬────┘ └────────┘ └───┬────┘ │
|
||||
│ │ │ │
|
||||
│ │ (권장) │ (필수) │
|
||||
│ ▼ ▼ │
|
||||
│ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ 07 VCS │ │ 05 Synology│ │
|
||||
│ └────────────┘ └─────┬──────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────┐ │
|
||||
│ │ 06 구글그룹│ │
|
||||
│ └────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 통합 실행 계획
|
||||
|
||||
### 2.1 전체 Phase 목록 (30개)
|
||||
|
||||
```
|
||||
Phase 00: 통합 DB 마이그레이션 ─────────────────────── 0.5일
|
||||
│
|
||||
├─► 01-P1: 회의록 기본 구조 ─────────────────────── 2일
|
||||
├─► 02-P1: 사업 CRUD ────────────────────────────── 1.5일
|
||||
├─► 03-P1: 유지보수 기본 CRUD ───────────────────── 2일
|
||||
└─► 04-P1: 인증 환경 설정 ───────────────────────── 1일
|
||||
│
|
||||
├─► 04-P2: 비밀번호 인증 ──────────────────── 1.5일
|
||||
│ │
|
||||
│ └─► 04-P3: Google OAuth ─────────────── 1.5일
|
||||
│ │
|
||||
│ ├─► 04-P4: 비밀번호 찾기 ──────── 1일
|
||||
│ │ │
|
||||
│ │ └─► 04-P5: 로그인 UI ────── 1일
|
||||
│ │ │
|
||||
│ │ └─► 05-P1: Synology API ─ 1.5일
|
||||
│ │ │
|
||||
│ │ └─► 05-P2: UI + 테스트 ─ 1일
|
||||
│ │
|
||||
│ └─► 06-P1: OAuth Scope 확장 ───── 2일
|
||||
│ │
|
||||
│ └─► 06-P2: 그룹 게시물 조회 ─ 3일
|
||||
│ │
|
||||
│ └─► 06-P3: 주간보고 공유 ─ 3일
|
||||
│ │
|
||||
│ └─► 06-P4: 테스트 ─── 2일
|
||||
│
|
||||
├─► 01-P2: AI 분석 연동 ─────────────────────────── 2일
|
||||
│ │
|
||||
│ └─► 01-P3: TODO 기능 ──────────────────────── 2일
|
||||
│ │
|
||||
│ └─► 01-P4: 주간보고 연계 ────────────── 1일
|
||||
│
|
||||
├─► 02-P2: 프로젝트-사업 연결 ───────────────────── 1일
|
||||
│ │
|
||||
│ └─► 02-P3: 사업 주간보고 취합 ─────────────── 1.5일
|
||||
│ │
|
||||
│ └─► 02-P4: 테스트 ───────────────────── 0.5일
|
||||
│
|
||||
├─► 03-P2: 파일 업로드 + AI 파싱 ────────────────── 2일
|
||||
│ │
|
||||
│ └─► 03-P3: 주간보고 연계 ──────────────────── 2일
|
||||
│ │
|
||||
│ └─► 03-P4: 통계 + 테스트 ────────────── 1일
|
||||
│
|
||||
└─► 07-P1: VCS 서버/계정 관리 ───────────────────── 3일
|
||||
│
|
||||
└─► 07-P2: 저장소 관리 ────────────────────── 2일
|
||||
│
|
||||
├─► 07-P3: Git 커밋 수집 ────────────── 3일
|
||||
│
|
||||
└─► 07-P4: SVN 커밋 수집 ────────────── 3일
|
||||
│
|
||||
└─► 07-P5: 커밋 조회 화면 ──────── 3일
|
||||
│
|
||||
└─► 07-P6: 자동화 + 테스트 ── 2일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 단계별 상세 실행 계획
|
||||
|
||||
### 📍 STAGE 0: 통합 DB 마이그레이션 (0.5일)
|
||||
|
||||
> **목표**: 모든 테이블을 한 번에 생성하여 이후 작업 효율화
|
||||
|
||||
| 순번 | Phase ID | 작업 내용 | 소요 |
|
||||
|:----:|:--------:|----------|:----:|
|
||||
| 1 | **00** | 16개 테이블 + ALTER 통합 DDL 실행 | 0.5일 |
|
||||
|
||||
**생성 테이블 목록**:
|
||||
```sql
|
||||
-- 01. 회의록/TODO
|
||||
wr_meeting, wr_meeting_attendee, wr_meeting_agenda, wr_todo
|
||||
|
||||
-- 02. 사업
|
||||
wr_business, wr_business_weekly_report
|
||||
ALTER wr_project_info ADD business_id
|
||||
|
||||
-- 03. 유지보수
|
||||
wr_maintenance_task, wr_maintenance_upload_batch
|
||||
|
||||
-- 04~06. 인증/그룹
|
||||
ALTER wr_employee_info ADD password_hash, google_*, synology_*, token_*
|
||||
wr_login_history, wr_google_group, wr_report_group_share
|
||||
|
||||
-- 07. VCS
|
||||
wr_vcs_server, wr_employee_vcs_account, wr_repository, wr_commit_log
|
||||
```
|
||||
|
||||
**완료 기준**: 모든 테이블 생성 확인, FK 관계 검증
|
||||
|
||||
---
|
||||
|
||||
### 📍 STAGE 1: 기반 구축 - 병렬 진행 (1주차)
|
||||
|
||||
> **목표**: 독립적인 기본 CRUD 4개 동시 진행
|
||||
|
||||
| 순번 | Phase ID | 작업 내용 | 소요 | 담당 |
|
||||
|:----:|:--------:|----------|:----:|:----:|
|
||||
| 2 | **01-P1** | 회의록 기본 구조 (Tiptap, CRUD) | 2일 | A |
|
||||
| 3 | **02-P1** | 사업 CRUD | 1.5일 | B |
|
||||
| 4 | **03-P1** | 유지보수 기본 CRUD | 2일 | C |
|
||||
| 5 | **04-P1** | 인증 환경 설정 (환경변수, Google Console) | 1일 | D |
|
||||
|
||||
**병렬 진행 가능**: ✅ 4개 모두 독립적
|
||||
|
||||
**완료 기준**:
|
||||
- [ ] 01-P1: 회의록 목록/작성/상세 화면 동작
|
||||
- [ ] 02-P1: 사업 목록/등록/수정/삭제 동작
|
||||
- [ ] 03-P1: 유지보수 목록/등록/상세/상태변경 동작
|
||||
- [ ] 04-P1: Google OAuth 콘솔 설정 완료, 환경변수 설정
|
||||
|
||||
---
|
||||
|
||||
### 📍 STAGE 2: 인증 체계 구축 (1~2주차)
|
||||
|
||||
> **목표**: Gmail OAuth 완성 → Synology SSO 확장
|
||||
|
||||
| 순번 | Phase ID | 작업 내용 | 소요 | 선행 |
|
||||
|:----:|:--------:|----------|:----:|:----:|
|
||||
| 6 | **04-P2** | 비밀번호 인증 (bcrypt) | 1.5일 | 04-P1 |
|
||||
| 7 | **04-P3** | Google OAuth 연동 | 1.5일 | 04-P2 |
|
||||
| 8 | **04-P4** | 비밀번호 찾기 + 이메일 발송 | 1일 | 04-P3 |
|
||||
| 9 | **04-P5** | 로그인 UI + 테스트 | 1일 | 04-P4 |
|
||||
| 10 | **05-P1** | Synology SSO API | 1.5일 | **04-P5** |
|
||||
| 11 | **05-P2** | Synology UI + 테스트 | 1일 | 05-P1 |
|
||||
|
||||
**병렬 진행 가능**: ❌ 순차 진행 필수 (의존성)
|
||||
|
||||
**완료 기준**:
|
||||
- [ ] 04-P5: Google 로그인/비밀번호 로그인 모두 동작
|
||||
- [ ] 05-P2: Synology 로그인 동작, 마이페이지 계정 연결 표시
|
||||
|
||||
---
|
||||
|
||||
### 📍 STAGE 3: 핵심 기능 개발 - 병렬 진행 (2~3주차)
|
||||
|
||||
> **목표**: AI 연동 기능 3개 + VCS 기반 동시 진행
|
||||
|
||||
| 순번 | Phase ID | 작업 내용 | 소요 | 선행 | 담당 |
|
||||
|:----:|:--------:|----------|:----:|:----:|:----:|
|
||||
| 12 | **01-P2** | 회의록 AI 분석 연동 | 2일 | 01-P1 | A |
|
||||
| 13 | **02-P2** | 프로젝트-사업 연결 | 1일 | 02-P1 | B |
|
||||
| 14 | **03-P2** | 파일 업로드 + AI 파싱 | 2일 | 03-P1 | C |
|
||||
| 15 | **07-P1** | VCS 서버/계정 관리 | 3일 | 00 | D |
|
||||
|
||||
**병렬 진행 가능**: ✅ 4개 모두 병렬 가능
|
||||
|
||||
**완료 기준**:
|
||||
- [ ] 01-P2: 회의록 저장 → AI 분석 → 결과 표시
|
||||
- [ ] 02-P2: 프로젝트에 사업 배정, 주간보고에 사업명 표시
|
||||
- [ ] 03-P2: 엑셀 업로드 → AI 파싱 → 검토 화면 표시
|
||||
- [ ] 07-P1: VCS 서버 관리, 마이페이지 계정 설정
|
||||
|
||||
---
|
||||
|
||||
### 📍 STAGE 4: 핵심 기능 심화 (3~4주차)
|
||||
|
||||
> **목표**: 각 기능의 핵심 로직 완성
|
||||
|
||||
| 순번 | Phase ID | 작업 내용 | 소요 | 선행 | 담당 |
|
||||
|:----:|:--------:|----------|:----:|:----:|:----:|
|
||||
| 16 | **01-P3** | TODO 기능 | 2일 | 01-P2 | A |
|
||||
| 17 | **02-P3** | 사업 주간보고 AI 취합 | 1.5일 | 02-P2 | B |
|
||||
| 18 | **03-P3** | 유지보수-주간보고 연계 | 2일 | 03-P2 | C |
|
||||
| 19 | **07-P2** | 저장소 관리 CRUD | 2일 | 07-P1 | D |
|
||||
|
||||
**병렬 진행 가능**: ✅ 4개 모두 병렬 가능
|
||||
|
||||
**완료 기준**:
|
||||
- [ ] 01-P3: TODO 목록/상태변경/담당자지정
|
||||
- [ ] 02-P3: 사업별 주간보고 AI 취합 생성
|
||||
- [ ] 03-P3: 주간보고 작성 시 유지보수 업무 연계
|
||||
- [ ] 07-P2: 프로젝트 상세에서 저장소 추가/수정/삭제
|
||||
|
||||
---
|
||||
|
||||
### 📍 STAGE 5: VCS 연동 + 구글 그룹 (4~5주차)
|
||||
|
||||
> **목표**: 외부 시스템 연동 완성
|
||||
|
||||
| 순번 | Phase ID | 작업 내용 | 소요 | 선행 | 담당 |
|
||||
|:----:|:--------:|----------|:----:|:----:|:----:|
|
||||
| 20 | **06-P1** | OAuth Scope 확장 + 토큰 저장 | 2일 | **04-P5** | A |
|
||||
| 21 | **07-P3** | Git 커밋 수집 | 3일 | 07-P2 | B |
|
||||
| 22 | **07-P4** | SVN 커밋 수집 | 3일 | 07-P2 | C |
|
||||
|
||||
**병렬 진행**:
|
||||
- 06-P1 ↔ 07-P3, 07-P4 병렬 가능
|
||||
- 07-P3 ↔ 07-P4 병렬 가능
|
||||
|
||||
**완료 기준**:
|
||||
- [ ] 06-P1: Gmail API 토큰 저장, 갱신 로직
|
||||
- [ ] 07-P3: Git 저장소 커밋 수집 → DB 저장
|
||||
- [ ] 07-P4: SVN 저장소 커밋 수집 → DB 저장
|
||||
|
||||
---
|
||||
|
||||
### 📍 STAGE 6: 기능 연결 및 UI (5~6주차)
|
||||
|
||||
> **목표**: 각 기능의 UI 완성 및 연계
|
||||
|
||||
| 순번 | Phase ID | 작업 내용 | 소요 | 선행 |
|
||||
|:----:|:--------:|----------|:----:|:----:|
|
||||
| 23 | **01-P4** | 주간보고-TODO 연계 | 1일 | 01-P3 |
|
||||
| 24 | **02-P4** | 사업 테스트 | 0.5일 | 02-P3 |
|
||||
| 25 | **03-P4** | 유지보수 통계 + 테스트 | 1일 | 03-P3 |
|
||||
| 26 | **06-P2** | 그룹 게시물 조회 | 3일 | 06-P1 |
|
||||
| 27 | **07-P5** | 커밋 조회 화면 | 3일 | 07-P3, 07-P4 |
|
||||
|
||||
**완료 기준**:
|
||||
- [ ] 01-P4: 주간보고 작성 시 유사 TODO 팝업
|
||||
- [ ] 02-P4: 사업 전체 플로우 검증
|
||||
- [ ] 03-P4: 통계 대시보드 표시
|
||||
- [ ] 06-P2: 그룹 게시물 목록/상세 조회
|
||||
- [ ] 07-P5: 프로젝트 커밋 조회 페이지, 주간보고 커밋 참고
|
||||
|
||||
---
|
||||
|
||||
### 📍 STAGE 7: 마무리 (6~7주차)
|
||||
|
||||
> **목표**: 남은 기능 완성 + 전체 테스트
|
||||
|
||||
| 순번 | Phase ID | 작업 내용 | 소요 | 선행 |
|
||||
|:----:|:--------:|----------|:----:|:----:|
|
||||
| 28 | **06-P3** | 주간보고 그룹 공유 | 3일 | 06-P2 |
|
||||
| 29 | **06-P4** | 구글 그룹 테스트 | 2일 | 06-P3 |
|
||||
| 30 | **07-P6** | VCS 자동화 + 테스트 | 2일 | 07-P5 |
|
||||
|
||||
**완료 기준**:
|
||||
- [ ] 06-P4: 그룹 공유 전체 플로우 검증
|
||||
- [ ] 07-P6: Cron 자동 동기화, 인증 암호화
|
||||
|
||||
---
|
||||
|
||||
### 📍 STAGE 8: 통합 테스트 (7~8주차)
|
||||
|
||||
> **목표**: 전체 시스템 통합 테스트 및 버그 수정
|
||||
|
||||
| 순번 | 작업 내용 | 소요 |
|
||||
|:----:|----------|:----:|
|
||||
| 31 | 전체 기능 통합 테스트 | 3일 |
|
||||
| 32 | 버그 수정 및 최적화 | 2일 |
|
||||
| 33 | 문서화 및 배포 준비 | 1일 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 간트 차트 (주차별)
|
||||
|
||||
```
|
||||
Week 1 2 3 4 5 6 7 8
|
||||
┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Stage 0 ██ DB 마이그레이션
|
||||
┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
01 회의 ████████████████████████ 회의록+TODO
|
||||
02 사업 ████████████████ 사업-프로젝트
|
||||
03 유지 ████████████████████████ 유지보수
|
||||
04 OAuth████████████████████ Gmail OAuth
|
||||
05 Syno ████████ Synology SSO
|
||||
06 그룹 ████████████████████████ 구글 그룹
|
||||
07 VCS ████████████████████████████████████████ SVN/Git
|
||||
┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
통합테스트 ████████████
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 단독 실행 가능 Phase (빠른 성과)
|
||||
|
||||
### 5.1 즉시 실행 가능 (의존성 없음) ⭐
|
||||
|
||||
| 순위 | Phase | 작업 내용 | 소요 | 난이도 |
|
||||
|:----:|:-----:|----------|:----:|:------:|
|
||||
| 1 | 00 | 통합 DB 마이그레이션 | 0.5일 | ⭐ |
|
||||
| 2 | 02-P1 | 사업 CRUD | 1.5일 | ⭐ |
|
||||
| 3 | 04-P1 | 인증 환경 설정 | 1일 | ⭐ |
|
||||
| 4 | 03-P1 | 유지보수 기본 CRUD | 2일 | ⭐⭐ |
|
||||
| 5 | 01-P1 | 회의록 기본 구조 | 2일 | ⭐⭐ |
|
||||
|
||||
### 5.2 선행 1개만 필요 (빠른 진행)
|
||||
|
||||
| 순위 | Phase | 작업 내용 | 소요 | 선행 | 난이도 |
|
||||
|:----:|:-----:|----------|:----:|:----:|:------:|
|
||||
| 6 | 02-P2 | 프로젝트-사업 연결 | 1일 | 02-P1 | ⭐ |
|
||||
| 7 | 04-P2 | 비밀번호 인증 | 1.5일 | 04-P1 | ⭐⭐ |
|
||||
| 8 | 07-P1 | VCS 서버/계정 관리 | 3일 | 00 | ⭐⭐ |
|
||||
| 9 | 01-P3 | TODO 기능 | 2일 | 01-P2 | ⭐⭐ |
|
||||
|
||||
### 5.3 복사해서 빠르게 (04 완료 후)
|
||||
|
||||
| 순위 | Phase | 작업 내용 | 소요 | 선행 | 비고 |
|
||||
|:----:|:-----:|----------|:----:|:----:|:----:|
|
||||
| 10 | 05-P1~P2 | Synology SSO 전체 | 2.5일 | 04 완료 | 04 코드 90% 재사용 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 자동화 대상
|
||||
|
||||
### 6.1 DB 관련 (Phase 00)
|
||||
- [ ] 통합 DDL 스크립트 생성
|
||||
- [ ] 마이그레이션 롤백 스크립트
|
||||
|
||||
### 6.2 CRUD API 템플릿
|
||||
- [ ] 목록/상세/생성/수정/삭제 공통 패턴
|
||||
- [ ] 적용 대상: meeting, business, maintenance, vcs-server, repository
|
||||
|
||||
### 6.3 Vue 컴포넌트 템플릿
|
||||
- [ ] 목록 페이지 (필터 + 테이블/카드)
|
||||
- [ ] 상세/수정 페이지
|
||||
- [ ] 모달 컴포넌트
|
||||
|
||||
### 6.4 OAuth 공통 모듈
|
||||
- [ ] OAuth 시작/콜백 공통 함수
|
||||
- [ ] 토큰 저장/갱신 유틸
|
||||
|
||||
### 6.5 AI 프롬프트 모듈
|
||||
- [ ] OpenAI 호출 공통 함수
|
||||
- [ ] 프롬프트 템플릿 관리
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 관리
|
||||
|
||||
### 7.1 기술 리스크
|
||||
|
||||
| 리스크 | 영향 | 대응 방안 |
|
||||
|--------|:----:|----------|
|
||||
| Google OAuth 민감 scope 승인 지연 | 06 지연 | 내부용 먼저, 승인 후 외부 공개 |
|
||||
| SVN 서버 접근 불가 | 07 일부 지연 | Git만 먼저 완성 |
|
||||
| Synology SSO 설정 이슈 | 05 지연 | 문서 기반 사전 테스트 |
|
||||
| AI API 비용 | 운영 | 캐싱, 호출 최소화 |
|
||||
|
||||
### 7.2 일정 리스크
|
||||
|
||||
| 리스크 | 영향 | 대응 방안 |
|
||||
|--------|:----:|----------|
|
||||
| 개발자 부재 | 전체 지연 | 병렬 작업으로 분산 |
|
||||
| 요구사항 변경 | 해당 기능 지연 | Phase 단위 완료 후 변경 |
|
||||
| 테스트 이슈 | 마무리 지연 | Stage별 테스트로 분산 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 마일스톤
|
||||
|
||||
| 마일스톤 | 목표일 | 완료 기준 |
|
||||
|----------|:------:|----------|
|
||||
| **M1: DB 완료** | 1주차 | 모든 테이블 생성, FK 검증 |
|
||||
| **M2: 기본 CRUD 완료** | 2주차 | 01-P1, 02-P1, 03-P1, 04-P1 완료 |
|
||||
| **M3: 인증 완료** | 3주차 | 04 전체 + 05 전체 완료 |
|
||||
| **M4: AI 연동 완료** | 4주차 | 01-P2, 02-P3, 03-P2 완료 |
|
||||
| **M5: VCS 연동 완료** | 6주차 | 07 전체 완료 |
|
||||
| **M6: 구글 그룹 완료** | 7주차 | 06 전체 완료 |
|
||||
| **M7: 전체 완료** | 8주차 | 통합 테스트 + 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase별 체크리스트
|
||||
|
||||
|
||||
### Phase 00: 통합 DB 마이그레이션 ✅ 완료
|
||||
- [x] 시작일: 2026-01-11 완료일: 2026-01-11 소요: 0.5시간
|
||||
- [x] 01 테이블 4개 생성 (meeting, attendee, agenda, todo) ✅ 기존 존재
|
||||
- [x] 02 테이블 2개 생성 (business, business_weekly_report) ✅ 기존 존재
|
||||
- [x] 02 ALTER wr_project_info (business_id 추가) ✅ 기존 존재
|
||||
- [x] 03 테이블 2개 생성 (maintenance_task, upload_batch) ✅ 기존 존재
|
||||
- [x] 04 ALTER wr_employee_info (password, google 컬럼) ✅ 기존 존재
|
||||
- [x] 04 테이블 1개 생성 (login_history) ✅ 기존 존재
|
||||
- [x] 05 ALTER wr_employee_info (synology 컬럼) ✅ 기존 존재
|
||||
- [x] 06 ALTER wr_employee_info (token 컬럼) ✅ 기존 존재
|
||||
- [x] 06 테이블 2개 생성 (google_group, report_group_share) ✅ 기존 존재
|
||||
- [x] 07 테이블 4개 생성 (vcs_server, vcs_account, repository, commit_log) ✅ commit_log 신규 생성
|
||||
- [x] 인덱스 생성 확인 ✅
|
||||
- [x] FK 관계 검증 ✅
|
||||
|
||||
---
|
||||
|
||||
### Phase 01-P1: 회의록 기본 구조 ✅ 완료
|
||||
- [x] 시작일시: 2026-01-11 17:05 종료일시: 2026-01-11 17:45 수행시간: 40분
|
||||
- [x] Tiptap 에디터 컴포넌트 구성 ⚠️ (textarea로 구현, Tiptap 설치 필요)
|
||||
- [x] 회의록 CRUD API (list, detail, create, update, delete) ✅
|
||||
- [x] 회의록 목록 페이지 (/meeting) ✅
|
||||
- [x] 회의록 작성 페이지 (/meeting/write) ✅
|
||||
- [x] 회의록 상세 페이지 (/meeting/[id]) ✅
|
||||
- [x] 참석자 선택 (내부/외부) ✅
|
||||
- [x] 프로젝트/내부업무 구분 ✅
|
||||
|
||||
### Phase 01-P2: AI 분석 연동
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] OpenAI 프롬프트 구현 (회의 정리)
|
||||
- [ ] 저장 시 자동 AI 분석 실행
|
||||
- [ ] AI 결과 → 안건 테이블 저장
|
||||
- [ ] AI 결과 → TODO 후보 추출
|
||||
- [ ] 상세 화면에 AI 분석 결과 표시
|
||||
- [ ] 재분석 기능
|
||||
- [ ] 확정 기능 (→ TODO 생성)
|
||||
|
||||
### Phase 01-P3: TODO 기능
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] TODO CRUD API
|
||||
- [ ] TODO 목록 페이지 (/todo)
|
||||
- [ ] 내 TODO 필터
|
||||
- [ ] 상태 변경 (대기/완료/폐기)
|
||||
- [ ] 담당자 지정
|
||||
- [ ] 프로젝트 연결
|
||||
|
||||
### Phase 01-P4: 주간보고 연계
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] 주간보고 작성 시 유사 TODO 감지 API
|
||||
- [ ] 유사 TODO 팝업 UI
|
||||
- [ ] TODO 완료 연계 처리
|
||||
- [ ] 테스트 및 버그 수정
|
||||
|
||||
---
|
||||
|
||||
### Phase 02-P1: 사업 CRUD ✅ 완료
|
||||
- [x] 시작일시: 2026-01-11 00:28 KST 종료일시: 2026-01-11 00:31 KST 수행시간: 3분
|
||||
- [x] 사업 CRUD API (list, detail, create, update, delete) ✅
|
||||
- [x] 사업 목록 페이지 (/business) ✅
|
||||
- [x] 사업 상세 페이지 (/business/[id]) ✅
|
||||
- [x] 사업 등록/수정 모달 ✅
|
||||
- [ ] 메뉴 권한 설정 (매니저 이상) ⏳ 추후
|
||||
|
||||
### Phase 02-P2: 프로젝트-사업 연결 ✅ 완료
|
||||
- [x] 시작일시: 2026-01-11 01:02 KST 종료일시: 2026-01-11 01:08 KST 수행시간: 6분
|
||||
- [x] 프로젝트 수정 화면에 사업 선택 추가 ✅
|
||||
- [x] 프로젝트 배정 API (business_id 필드) ✅
|
||||
- [x] 사업 상세에 소속 프로젝트 목록 ✅
|
||||
- [x] 주간보고 작성 시 사업명 표시 ⏳ 추후
|
||||
|
||||
### Phase 02-P3: 사업 주간보고 취합 ✅ 완료
|
||||
- [x] 시작일시: 2026-01-11 01:10 KST 종료일시: 2026-01-11 01:18 KST 수행시간: 8분
|
||||
- [x] OpenAI 프롬프트 구현 (취합 요약) ✅
|
||||
- [x] 사업 주간보고 취합 API ✅
|
||||
- [x] 사업 주간보고 상세 페이지 ✅
|
||||
- [x] 확정 기능 ✅
|
||||
|
||||
### Phase 02-P4: 테스트 ✅ 완료
|
||||
- [x] 시작일시: 2026-01-11 01:20 KST 종료일시: 2026-01-11 01:24 KST 수행시간: 4분
|
||||
- [x] 전체 플로우 테스트 ✅
|
||||
- [x] 기존 취합보고와 연계 확인 ✅
|
||||
- [x] 버그 수정 (없음) ✅
|
||||
|
||||
---
|
||||
|
||||
### Phase 03-P1: 유지보수 기본 CRUD ✅ 완료
|
||||
- [x] 시작일시: 2026-01-11 00:51 KST 종료일시: 2026-01-11 00:56 KST 수행시간: 5분
|
||||
- [x] 유지보수 CRUD API ✅
|
||||
- [x] 목록 페이지 (/maintenance) ✅
|
||||
- [x] 상세 페이지 (/maintenance/[id]) ✅
|
||||
- [x] 등록/수정 화면 ✅
|
||||
- [x] 상태 변경 기능 ✅
|
||||
- [x] 반영 체크 (개발/운영/고객확인) ✅
|
||||
|
||||
### Phase 03-P2: 파일 업로드 + AI 파싱 ✅ 완료
|
||||
- [x] 시작일시: 2026-01-11 01:26 KST 종료일시: 2026-01-11 01:33 KST 수행시간: 7분
|
||||
- [x] 파일 업로드 API (엑셀/CSV) ✅
|
||||
- [x] SheetJS 연동 ✅ (npm install xlsx 필요)
|
||||
- [x] OpenAI 프롬프트 구현 (파싱) ✅
|
||||
- [x] 파싱 결과 검토 화면 ✅
|
||||
- [x] 중복 감지 로직 ✅
|
||||
- [x] 일괄 등록 기능 ✅
|
||||
|
||||
### Phase 03-P3: 주간보고 연계 🔄 진행중
|
||||
- [x] 시작일시: 2026-01-11 01:35 KST 종료일시: ____ 수행시간: ____
|
||||
- [ ] 주간보고 작성 시 유지보수 업무 조회 API
|
||||
- [ ] OpenAI 프롬프트 (실적 문장 생성)
|
||||
- [ ] 유사 실적 병합 기능
|
||||
- [ ] 연계 정보 저장
|
||||
- [ ] 주간보고 작성 화면 수정
|
||||
|
||||
### Phase 03-P4: 통계 + 테스트
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] 통계 API (주간/월간/담당자별)
|
||||
- [ ] 통계 대시보드 페이지
|
||||
- [ ] 전체 테스트 및 버그 수정
|
||||
|
||||
---
|
||||
|
||||
### Phase 04-P1: 인증 환경 설정
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] Google Cloud Console OAuth 설정
|
||||
- [ ] 환경 변수 설정 (GOOGLE_*, SMTP_*)
|
||||
- [ ] wr_employee_info 컬럼 추가 완료 확인
|
||||
|
||||
### Phase 04-P2: 비밀번호 인증
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] bcrypt 해시 처리 유틸
|
||||
- [ ] 이메일/비밀번호 로그인 API
|
||||
- [ ] 비밀번호 변경 API
|
||||
- [ ] 비밀번호 초기화 API (관리자)
|
||||
|
||||
### Phase 04-P3: Google OAuth
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] Google OAuth 시작 API (/api/auth/google)
|
||||
- [ ] Google 콜백 API (/api/auth/google/callback)
|
||||
- [ ] 사용자 매칭 로직 (email 기준)
|
||||
- [ ] 비밀번호 미설정 시 리다이렉트
|
||||
|
||||
### Phase 04-P4: 비밀번호 찾기 + 이메일
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] nodemailer 설정
|
||||
- [ ] 이메일 발송 유틸
|
||||
- [ ] 비밀번호 찾기 API (이름+이메일+핸드폰)
|
||||
- [ ] 임시 비밀번호 생성 및 발송
|
||||
- [ ] 비밀번호 찾기 페이지
|
||||
|
||||
### Phase 04-P5: 로그인 UI + 테스트
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] 로그인 페이지 수정 (OAuth + 비밀번호)
|
||||
- [ ] 비밀번호 설정 페이지
|
||||
- [ ] 로그인 실패 페이지
|
||||
- [ ] 마이페이지 비밀번호 변경 UI
|
||||
- [ ] 관리자 사용자 관리 수정
|
||||
- [ ] 전체 플로우 테스트
|
||||
|
||||
---
|
||||
|
||||
### Phase 05-P1: Synology SSO API
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] Synology SSO Server 애플리케이션 등록
|
||||
- [ ] 환경 변수 설정 (SYNOLOGY_*)
|
||||
- [ ] Synology OAuth 시작 API (/api/auth/synology)
|
||||
- [ ] Synology 콜백 API (/api/auth/synology/callback)
|
||||
- [ ] 사용자 매칭 로직
|
||||
|
||||
### Phase 05-P2: Synology UI + 테스트
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] 로그인 페이지에 Synology 버튼 추가
|
||||
- [ ] 마이페이지 외부 계정 연결 표시
|
||||
- [ ] 로그인 이력에 login_type 기록
|
||||
- [ ] 전체 플로우 테스트
|
||||
|
||||
---
|
||||
|
||||
### Phase 06-P1: OAuth Scope 확장
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] Google Cloud Console scope 추가 (gmail.readonly, gmail.send)
|
||||
- [ ] wr_employee_info 토큰 컬럼 확인
|
||||
- [ ] OAuth 콜백에서 토큰 저장
|
||||
- [ ] 토큰 갱신 로직
|
||||
|
||||
### Phase 06-P2: 그룹 게시물 조회
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] wr_google_group 테이블에 그룹 등록
|
||||
- [ ] 그룹 목록 API
|
||||
- [ ] 그룹 게시물 목록 API (Gmail API 연동)
|
||||
- [ ] 게시물 상세 API
|
||||
- [ ] 그룹 게시물 조회 페이지 (/google-group)
|
||||
|
||||
### Phase 06-P3: 주간보고 그룹 공유
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] 그룹 공유 API (Gmail 발송)
|
||||
- [ ] 공유 이력 API
|
||||
- [ ] 이메일 본문 템플릿
|
||||
- [ ] 주간보고 상세에 공유 UI 추가
|
||||
|
||||
### Phase 06-P4: 테스트 + 마무리
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] 전체 플로우 테스트
|
||||
- [ ] 토큰 만료 시 갱신 테스트
|
||||
- [ ] 오류 처리 (권한 없음 등)
|
||||
- [ ] 관리자 그룹 목록 관리 페이지
|
||||
|
||||
---
|
||||
|
||||
### Phase 07-P1: VCS 서버/계정 관리
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] VCS 서버 CRUD API (관리자)
|
||||
- [ ] VCS 서버 관리 페이지 (/admin/vcs-server)
|
||||
- [ ] 사용자 VCS 계정 API
|
||||
- [ ] 마이페이지 VCS 계정 설정 UI
|
||||
|
||||
### Phase 07-P2: 저장소 관리
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] 저장소 CRUD API
|
||||
- [ ] 프로젝트 상세에 저장소 관리 UI
|
||||
- [ ] 저장소 추가/수정 모달
|
||||
|
||||
### Phase 07-P3: Git 커밋 수집
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] simple-git 패키지 설치
|
||||
- [ ] Git clone/pull 로직
|
||||
- [ ] 커밋 로그 파싱
|
||||
- [ ] 작성자 매칭 (VCS 계정 기반)
|
||||
- [ ] DB 저장
|
||||
|
||||
### Phase 07-P4: SVN 커밋 수집
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] svn CLI 연동
|
||||
- [ ] svn log 실행 및 XML 파싱
|
||||
- [ ] 작성자 매칭
|
||||
- [ ] DB 저장
|
||||
|
||||
### Phase 07-P5: 커밋 조회 화면
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] 프로젝트별 커밋 조회 API
|
||||
- [ ] 프로젝트 커밋 조회 페이지 (/project/[id]/commits)
|
||||
- [ ] 필터 (기간, 저장소, 작성자)
|
||||
- [ ] 주간보고 작성 시 커밋 참고 UI
|
||||
- [ ] 새로고침 버튼
|
||||
|
||||
### Phase 07-P6: 자동화 + 테스트
|
||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
||||
- [ ] Cron Job 설정 (매일 새벽 자동 동기화)
|
||||
- [ ] 인증 정보 암호화
|
||||
- [ ] 전체 플로우 테스트
|
||||
- [ ] 오류 처리
|
||||
|
||||
---
|
||||
|
||||
## 10. 작업 완료 결과 요약
|
||||
|
||||
### 전체 Phase별 시간 기록
|
||||
|
||||
| Stage | Phase ID | 작업 내용 | 시작 | 완료 | 소요시간 |
|
||||
|:-----:|:--------:|----------|:----:|:----:|:--------:|
|
||||
| 0 | 00 | 통합 DB 마이그레이션 | 01-11 | 01-11 | 0.5h ✅ |
|
||||
| 1 | 01-P1 | 회의록 기본 구조 | 01-11 17:05 | 01-11 17:45 | 40분 ✅ |
|
||||
| 1 | 02-P1 | 사업 CRUD | 01-11 00:28 | 01-11 00:31 | 3분 ✅ |
|
||||
| 1 | 03-P1 | 유지보수 기본 CRUD | 01-11 00:51 | 01-11 00:56 | 5분 ✅ |
|
||||
| 1 | 04-P1 | 인증 환경 설정 | - | - | - |
|
||||
| 2 | 04-P2 | 비밀번호 인증 | - | - | - |
|
||||
| 2 | 04-P3 | Google OAuth | - | - | - |
|
||||
| 2 | 04-P4 | 비밀번호 찾기 | - | - | - |
|
||||
| 2 | 04-P5 | 로그인 UI | - | - | - |
|
||||
| 2 | 05-P1 | Synology API | - | - | - |
|
||||
| 2 | 05-P2 | Synology UI | - | - | - |
|
||||
| 3 | 01-P2 | AI 분석 연동 | - | - | - |
|
||||
| 3 | 02-P2 | 프로젝트-사업 연결 | - | - | - |
|
||||
| 3 | 03-P2 | 파일 업로드 + AI 파싱 | - | - | - |
|
||||
| 3 | 07-P1 | VCS 서버/계정 관리 | - | - | - |
|
||||
| 4 | 01-P3 | TODO 기능 | - | - | - |
|
||||
| 4 | 02-P3 | 사업 주간보고 취합 | - | - | - |
|
||||
| 4 | 03-P3 | 유지보수-주간보고 연계 | - | - | - |
|
||||
| 4 | 07-P2 | 저장소 관리 | - | - | - |
|
||||
| 5 | 06-P1 | OAuth Scope 확장 | - | - | - |
|
||||
| 5 | 07-P3 | Git 커밋 수집 | - | - | - |
|
||||
| 5 | 07-P4 | SVN 커밋 수집 | - | - | - |
|
||||
| 6 | 01-P4 | 주간보고-TODO 연계 | - | - | - |
|
||||
| 6 | 02-P4 | 사업 테스트 | - | - | - |
|
||||
| 6 | 03-P4 | 유지보수 통계 | - | - | - |
|
||||
| 6 | 06-P2 | 그룹 게시물 조회 | - | - | - |
|
||||
| 6 | 07-P5 | 커밋 조회 화면 | - | - | - |
|
||||
| 7 | 06-P3 | 주간보고 그룹 공유 | - | - | - |
|
||||
| 7 | 06-P4 | 구글 그룹 테스트 | - | - | - |
|
||||
| 7 | 07-P6 | VCS 자동화 | - | - | - |
|
||||
| 8 | - | 통합 테스트 | - | - | - |
|
||||
| | | | | **총 소요시간** | **-** |
|
||||
|
||||
---
|
||||
|
||||
### 마일스톤 달성 현황
|
||||
|
||||
| 마일스톤 | 목표일 | 실제 완료일 | 상태 |
|
||||
|----------|:------:|:----------:|:----:|
|
||||
| M1: DB 완료 | 1주차 | 2026-01-11 | ✅ |
|
||||
| M2: 기본 CRUD 완료 | - | - | ⬜ |
|
||||
| M3: 인증 완료 | - | - | ⬜ |
|
||||
| M4: AI 연동 완료 | - | - | ⬜ |
|
||||
| M5: VCS 연동 완료 | - | - | ⬜ |
|
||||
| M6: 구글 그룹 완료 | - | - | ⬜ |
|
||||
| M7: 전체 완료 | - | - | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 11. 참조 문서
|
||||
|
||||
| # | 문서명 | 파일명 |
|
||||
|:-:|--------|--------|
|
||||
| 01 | 회의록 + TODO 작업계획서 | 01_회의록_TODO_작업계획서.md |
|
||||
| 02 | 사업-프로젝트 계층 작업계획서 | 02_사업_프로젝트_계층구조_작업계획서.md |
|
||||
| 03 | 유지보수 업무관리 작업계획서 | 03_유지보수_업무관리_작업계획서.md |
|
||||
| 04 | Gmail OAuth 로그인 작업계획서 | 04_Gmail_OAuth_로그인_작업계획서.md |
|
||||
| 05 | Synology SSO 연동 작업계획서 | 05_Synology_SSO_연동_작업계획서.md |
|
||||
| 06 | 구글 그룹 연동 작업계획서 | 06_구글그룹_연동_작업계획서.md |
|
||||
| 07 | SVN/Git 커밋 연동 작업계획서 | 07_SVN_Git_커밋내역_연동_작업계획서.md |
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 버전 | 날짜 | 변경 내용 | 작성자 |
|
||||
|:----:|:----:|----------|:------:|
|
||||
| 1.0 | 2026-01-10 | 최초 작성 | - |
|
||||
491
claude_temp/01_회의록_TODO_작업계획서.md
Normal file
491
claude_temp/01_회의록_TODO_작업계획서.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# 회의록 + TODO 기능 작업계획서
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 예상 기간: 5~7일
|
||||
> 우선순위: 1 (가장 쉬움)
|
||||
|
||||
---
|
||||
|
||||
## 1. 기능 개요
|
||||
|
||||
### 1.1 핵심 컨셉
|
||||
- 두서없이 작성한 회의 내용을 AI가 주제별로 정리
|
||||
- 결정/미결정 사항 자동 분류
|
||||
- 미결정 사항 중 액션 필요한 것을 TODO로 추출
|
||||
- TODO는 "언젠가 할 일" / "검토 필요한 것" / "보류된 것" 성격
|
||||
|
||||
### 1.2 회의록 유형
|
||||
| 유형 | 설명 |
|
||||
|:---:|------|
|
||||
| 프로젝트 회의 | 특정 프로젝트에 종속 |
|
||||
| 내부업무 회의 | 프로젝트 무관 (일반 내부 회의) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 모델
|
||||
|
||||
### 2.1 회의록 테이블 (wr_meeting)
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_meeting (
|
||||
meeting_id SERIAL PRIMARY KEY,
|
||||
|
||||
-- 기본 정보
|
||||
meeting_title VARCHAR(200) NOT NULL, -- 회의 제목
|
||||
meeting_type VARCHAR(20) NOT NULL, -- PROJECT: 프로젝트, INTERNAL: 내부업무
|
||||
project_id INTEGER REFERENCES wr_project_info(project_id), -- 프로젝트 (선택)
|
||||
|
||||
-- 일시/장소
|
||||
meeting_date DATE NOT NULL, -- 회의 일자
|
||||
start_time TIME, -- 시작 시간
|
||||
end_time TIME, -- 종료 시간
|
||||
location VARCHAR(100), -- 장소
|
||||
|
||||
-- 내용
|
||||
raw_content TEXT, -- 원본 내용 (위키 에디터 HTML)
|
||||
ai_summary TEXT, -- AI 정리 결과 (JSON)
|
||||
ai_status VARCHAR(20) DEFAULT 'NONE', -- NONE: 미분석, PENDING: 미확정, CONFIRMED: 확정
|
||||
ai_processed_at TIMESTAMP, -- AI 처리 일시
|
||||
ai_confirmed_at TIMESTAMP, -- 확정 일시
|
||||
|
||||
-- 메타
|
||||
author_id INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_ip VARCHAR(50),
|
||||
created_email VARCHAR(100),
|
||||
updated_ip VARCHAR(50),
|
||||
updated_email VARCHAR(100)
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_meeting_project ON wr_meeting(project_id);
|
||||
CREATE INDEX idx_meeting_date ON wr_meeting(meeting_date);
|
||||
CREATE INDEX idx_meeting_author ON wr_meeting(author_id);
|
||||
```
|
||||
|
||||
### 2.2 회의 참석자 테이블 (wr_meeting_attendee)
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_meeting_attendee (
|
||||
attendee_id SERIAL PRIMARY KEY,
|
||||
meeting_id INTEGER NOT NULL REFERENCES wr_meeting(meeting_id) ON DELETE CASCADE,
|
||||
|
||||
-- 내부 직원 (선택)
|
||||
employee_id INTEGER REFERENCES wr_employee_info(employee_id),
|
||||
|
||||
-- 외부 참석자 (직접 입력)
|
||||
external_name VARCHAR(50), -- 외부인 이름
|
||||
external_company VARCHAR(100), -- 외부인 소속
|
||||
|
||||
-- 하나는 필수
|
||||
CONSTRAINT chk_attendee CHECK (employee_id IS NOT NULL OR external_name IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_attendee_meeting ON wr_meeting_attendee(meeting_id);
|
||||
```
|
||||
|
||||
### 2.3 회의 안건 테이블 (wr_meeting_agenda)
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_meeting_agenda (
|
||||
agenda_id SERIAL PRIMARY KEY,
|
||||
meeting_id INTEGER NOT NULL REFERENCES wr_meeting(meeting_id) ON DELETE CASCADE,
|
||||
|
||||
agenda_no INTEGER NOT NULL, -- 안건 번호 (1, 2, 3...)
|
||||
agenda_title VARCHAR(200) NOT NULL, -- 안건 제목
|
||||
agenda_content TEXT, -- 안건 상세 내용
|
||||
|
||||
-- 결정 상태
|
||||
decision_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
-- DECIDED: 결정됨, PENDING: 미결정, IN_PROGRESS: 진행중
|
||||
|
||||
decision_content TEXT, -- 결정 내용 (결정된 경우)
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agenda_meeting ON wr_meeting_agenda(meeting_id);
|
||||
```
|
||||
|
||||
### 2.4 TODO 테이블 (wr_todo)
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_todo (
|
||||
todo_id SERIAL PRIMARY KEY,
|
||||
|
||||
-- 출처 (회의록에서 추출 시)
|
||||
source_type VARCHAR(20), -- MEETING: 회의록, MANUAL: 직접생성
|
||||
meeting_id INTEGER REFERENCES wr_meeting(meeting_id),
|
||||
agenda_id INTEGER REFERENCES wr_meeting_agenda(agenda_id),
|
||||
|
||||
-- 프로젝트 연결 (선택)
|
||||
project_id INTEGER REFERENCES wr_project_info(project_id),
|
||||
|
||||
-- 내용
|
||||
todo_title VARCHAR(300) NOT NULL, -- TODO 제목
|
||||
todo_description TEXT, -- 상세 설명
|
||||
|
||||
-- 담당/상태
|
||||
assignee_id INTEGER REFERENCES wr_employee_info(employee_id), -- 담당자 (선택)
|
||||
todo_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
-- PENDING: 대기, COMPLETED: 완료, DISCARDED: 폐기
|
||||
|
||||
completed_at TIMESTAMP, -- 완료 일시
|
||||
discarded_at TIMESTAMP, -- 폐기 일시
|
||||
discard_reason VARCHAR(200), -- 폐기 사유
|
||||
|
||||
-- 주간보고 연계
|
||||
linked_report_id INTEGER REFERENCES wr_weekly_report(report_id), -- 연계된 주간보고
|
||||
linked_at TIMESTAMP, -- 연계 일시
|
||||
|
||||
-- 메타
|
||||
author_id INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_ip VARCHAR(50),
|
||||
created_email VARCHAR(100),
|
||||
updated_ip VARCHAR(50),
|
||||
updated_email VARCHAR(100)
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_todo_assignee ON wr_todo(assignee_id);
|
||||
CREATE INDEX idx_todo_status ON wr_todo(todo_status);
|
||||
CREATE INDEX idx_todo_meeting ON wr_todo(meeting_id);
|
||||
CREATE INDEX idx_todo_project ON wr_todo(project_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API 설계
|
||||
|
||||
### 3.1 회의록 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/meeting/list | 회의록 목록 조회 |
|
||||
| GET | /api/meeting/[id]/detail | 회의록 상세 조회 |
|
||||
| POST | /api/meeting/create | 회의록 작성 (저장 시 AI 분석 자동 실행) |
|
||||
| PUT | /api/meeting/[id]/update | 회의록 수정 |
|
||||
| DELETE | /api/meeting/[id]/delete | 회의록 삭제 |
|
||||
| POST | /api/meeting/[id]/reanalyze | AI 재분석 실행 |
|
||||
| POST | /api/meeting/[id]/confirm | 분석 결과 확정 (→ TODO 생성) |
|
||||
|
||||
### 3.2 TODO API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/todo/list | TODO 목록 조회 |
|
||||
| GET | /api/todo/my | 내 TODO 목록 |
|
||||
| POST | /api/todo/create | TODO 직접 생성 |
|
||||
| PUT | /api/todo/[id]/update | TODO 수정 |
|
||||
| PUT | /api/todo/[id]/complete | TODO 완료 처리 |
|
||||
| PUT | /api/todo/[id]/discard | TODO 폐기 처리 |
|
||||
| DELETE | /api/todo/[id]/delete | TODO 삭제 |
|
||||
|
||||
### 3.3 주간보고 연계 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/todo/suggest-for-report | 주간보고 작성 시 유사 TODO 추천 |
|
||||
| POST | /api/todo/[id]/link-report | TODO-주간보고 연계 + 완료 처리 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 설계
|
||||
|
||||
### 4.0 에디터 선택: Tiptap
|
||||
|
||||
Vue3 네이티브 지원, 가볍고 커스터마이징 우수한 **Tiptap** 사용
|
||||
|
||||
```bash
|
||||
npm install @tiptap/vue-3 @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-placeholder
|
||||
```
|
||||
|
||||
### 4.1 회의록 목록 (/meeting)
|
||||
- 필터: 전체 / 프로젝트별 / 내부업무
|
||||
- 기간 검색
|
||||
- 카드형 또는 테이블형 목록
|
||||
- **분석 상태 표시**: 미분석 / 미확정 / 확정
|
||||
|
||||
### 4.2 회의록 작성 (/meeting/write)
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 회의록 작성 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 회의 유형: ○ 프로젝트 회의 ○ 내부업무 회의 │
|
||||
│ 프로젝트: [선택 (프로젝트 회의 시)] │
|
||||
│ │
|
||||
│ 제목: [_________________________] │
|
||||
│ 일시: [2026-01-10] [14:00] ~ [16:00] │
|
||||
│ 장소: [회의실 A] │
|
||||
│ │
|
||||
│ 참석자: [내부직원 선택] [+ 외부 추가] │
|
||||
│ - 조효성, 서혜원 │
|
||||
│ - 홍길동 (고객사) │
|
||||
│ │
|
||||
│ 회의 내용: (Tiptap 위키 에디터) │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ [B] [I] [U] [H1] [H2] [•] [1.] [Link] │ │
|
||||
│ │ ───────────────────────────────────────── │
|
||||
│ │ 오늘 PIMS 관련해서 얘기했는데... │ │
|
||||
│ │ • 화면 디자인은 기존 거 쓰기로 했고 │ │
|
||||
│ │ • DB 마이그레이션은 아직... │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [임시저장] [저장] │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
* 저장 시 자동으로 AI 분석 실행 → 상세 페이지로 이동
|
||||
```
|
||||
|
||||
### 4.3 회의록 상세 보기 (/meeting/[id])
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 📋 PIMS 프로젝트 킥오프 회의 [수정] │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 일시: 2026-01-10 14:00~16:00 │
|
||||
│ 장소: 회의실 A │
|
||||
│ 참석자: 조효성, 서혜원, 홍길동(고객사) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 📝 회의 내용 │
|
||||
│ ─────────────────────────────────────────────────────── │
|
||||
│ (위키 에디터로 작성된 원본 내용 렌더링 - HTML) │
|
||||
│ │
|
||||
│ 오늘 PIMS 관련해서 얘기했는데... │
|
||||
│ • 화면 디자인은 기존 거 쓰기로 했고 │
|
||||
│ • DB 마이그레이션은 아직 범위가... │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 🤖 AI 분석 결과 [재분석] [확정하기] │
|
||||
│ ─────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 1. 화면 디자인 │
|
||||
│ ✅ 결정: 기존 디자인 재사용 │
|
||||
│ │
|
||||
│ 2. DB 마이그레이션 │
|
||||
│ ⏳ 미결정: 범위 미확정, 다음주 재논의 │
|
||||
│ ☑ TODO: DB 마이그레이션 범위 정의 │
|
||||
│ │
|
||||
│ 3. 테스트 서버 │
|
||||
│ ⏳ 진행중 │
|
||||
│ ☑ TODO: AWS 견적 확인 (@조효성) │
|
||||
│ │
|
||||
│ ─────────────────────────────────────────────────────── │
|
||||
│ 상태: ⚠️ 미확정 (확정 시 선택된 TODO가 생성됩니다) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
* [확정하기] 클릭 → 체크된 TODO 항목 자동 생성
|
||||
* 이미 확정된 경우: "✅ 확정됨 (2026-01-10)" 표시, TODO 목록 링크
|
||||
```
|
||||
|
||||
### 4.4 회의록 수정 시 플로우
|
||||
```
|
||||
[수정 페이지에서 저장 클릭]
|
||||
↓
|
||||
내용 변경 감지?
|
||||
├─ NO → 그냥 저장
|
||||
└─ YES ↓
|
||||
|
||||
┌─────────────────────────────────────┐
|
||||
│ 회의 내용이 변경되었습니다. │
|
||||
│ │
|
||||
│ AI 분석을 다시 실행할까요? │
|
||||
│ (기존 분석 결과가 대체됩니다) │
|
||||
│ │
|
||||
│ [예, 재분석] [아니오] │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
* 이미 확정된 분석이 있으면 경고:
|
||||
"기존 TODO가 유지됩니다. 새 분석 결과로 추가 TODO를 생성할 수 있습니다."
|
||||
```
|
||||
|
||||
### 4.5 AI 분석 상태
|
||||
|
||||
| 상태 | 값 | 설명 |
|
||||
|:---:|:---:|------|
|
||||
| 미분석 | `NONE` | 아직 AI 분석 안됨 |
|
||||
| 미확정 | `PENDING` | 분석 완료, 사용자 확정 대기 |
|
||||
| 확정 | `CONFIRMED` | 확정됨, TODO 생성 완료 |
|
||||
|
||||
### 4.6 TODO 목록 (/todo)
|
||||
- 필터: 전체 / 내 TODO / 프로젝트별
|
||||
- 상태 필터: 대기 / 완료 / 폐기
|
||||
- 드래그앤드롭 또는 버튼으로 상태 변경
|
||||
|
||||
### 4.5 주간보고 작성 시 TODO 연계
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 💡 유사한 TODO가 있습니다 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 작성 중인 실적: │
|
||||
│ "AWS 테스트 서버 구축 완료" │
|
||||
│ │
|
||||
│ 유사 TODO: │
|
||||
│ ⏳ "AWS 견적 확인" (1/8 회의에서 생성) │
|
||||
│ │
|
||||
│ 이 TODO도 완료 처리할까요? │
|
||||
│ [예, 완료 처리] [아니오] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AI 프롬프트 설계
|
||||
|
||||
### 5.1 회의 내용 정리 프롬프트
|
||||
|
||||
```
|
||||
당신은 회의록 정리 전문가입니다.
|
||||
아래 회의 내용을 분석하여 JSON 형식으로 정리해주세요.
|
||||
|
||||
## 입력
|
||||
- 회의 제목: {title}
|
||||
- 프로젝트: {project_name}
|
||||
- 참석자: {attendees}
|
||||
- 원본 내용:
|
||||
{raw_content}
|
||||
|
||||
## 출력 형식
|
||||
{
|
||||
"agendas": [
|
||||
{
|
||||
"no": 1,
|
||||
"title": "안건 제목",
|
||||
"content": "상세 내용",
|
||||
"status": "DECIDED | PENDING | IN_PROGRESS",
|
||||
"decision": "결정 내용 (결정된 경우)",
|
||||
"todos": [
|
||||
{
|
||||
"title": "TODO 제목",
|
||||
"assignee": "담당자명 또는 null",
|
||||
"reason": "TODO로 추출한 이유"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"summary": "전체 회의 요약 (2-3문장)"
|
||||
}
|
||||
|
||||
## 규칙
|
||||
1. 안건은 주제별로 분리하여 넘버링
|
||||
2. 결정된 사항은 DECIDED, 추후 논의는 PENDING, 진행중은 IN_PROGRESS
|
||||
3. 미결정/진행중 사항 중 액션이 필요한 것은 todos로 추출
|
||||
4. 담당자가 언급되면 assignee에 기록 (없으면 null)
|
||||
5. JSON 외 다른 텍스트 출력 금지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 작업 일정
|
||||
|
||||
### Phase 1: 기본 구조 (2일) ✅ 완료
|
||||
- [x] 시작: 2026-01-11 17:05
|
||||
- [x] 완료: 2026-01-11 17:45
|
||||
- [x] 소요시간: 40분
|
||||
|
||||
**작업 내용:**
|
||||
- [x] DB 테이블 생성 (meeting, attendee, agenda, todo) ✅ 기존 존재
|
||||
- [x] Tiptap 에디터 컴포넌트 구성 ⚠️ textarea로 구현 (Tiptap 설치 필요)
|
||||
- [x] 회의록 CRUD API ✅
|
||||
- [x] 회의록 목록/작성 화면 ✅
|
||||
|
||||
**생성된 파일:**
|
||||
- backend/api/meeting/list.get.ts
|
||||
- backend/api/meeting/create.post.ts
|
||||
- backend/api/meeting/[id]/detail.get.ts
|
||||
- backend/api/meeting/[id]/update.put.ts
|
||||
- backend/api/meeting/[id]/delete.delete.ts
|
||||
- frontend/meeting/index.vue
|
||||
- frontend/meeting/write.vue
|
||||
- frontend/meeting/[id].vue
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: AI 분석 연동 (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 회의 내용 AI 분석 API (저장 시 자동 실행)
|
||||
- [ ] AI 정리 결과 → 안건 + TODO 추출 로직
|
||||
- [ ] 회의록 상세 화면 (원본 + AI 분석 결과)
|
||||
- [ ] 분석 결과 확정 기능 (→ TODO 자동 생성)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: TODO 기능 (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] TODO CRUD API
|
||||
- [ ] TODO 목록/상세 화면
|
||||
- [ ] 상태 변경 기능 (대기/완료/폐기)
|
||||
- [ ] 담당자 지정 기능
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 주간보고 연계 (1일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 주간보고 작성 시 유사 TODO 감지 (AI)
|
||||
- [ ] TODO 완료 연계 처리 (확인 후 업데이트)
|
||||
- [ ] 테스트 및 버그 수정
|
||||
|
||||
---
|
||||
|
||||
## 작업 완료 결과
|
||||
|
||||
### Phase별 작업 시간
|
||||
|
||||
| Phase | 작업 내용 | 시작 | 완료 | 소요시간 |
|
||||
|:-----:|----------|:----:|:----:|:--------:|
|
||||
| 1 | 기본 구조 (DB, API, 화면) | 01-11 17:05 | 01-11 17:45 | 40분 ✅ |
|
||||
| 2 | AI 분석 연동 | - | - | - |
|
||||
| 3 | TODO 기능 | - | - | - |
|
||||
| 4 | 주간보고 연계 | - | - | - |
|
||||
| | | | **총 소요시간** | **-** |
|
||||
|
||||
---
|
||||
|
||||
### 생성/수정된 파일
|
||||
|
||||
| 구분 | 파일 | 작업 |
|
||||
|------|------|:----:|
|
||||
| **DB** | wr_meeting | 신규 테이블 |
|
||||
| **DB** | wr_meeting_attendee | 신규 테이블 |
|
||||
| **DB** | wr_meeting_agenda | 신규 테이블 |
|
||||
| **DB** | wr_todo | 신규 테이블 |
|
||||
| **API** | backend/api/meeting/*.ts | 신규 |
|
||||
| **API** | backend/api/todo/*.ts | 신규 |
|
||||
| **Frontend** | frontend/pages/meeting/*.vue | 신규 |
|
||||
| **Frontend** | frontend/pages/todo/*.vue | 신규 |
|
||||
| **Frontend** | frontend/components/editor/TiptapEditor.vue | 신규 |
|
||||
| **Utils** | backend/utils/openai.ts | 수정 (프롬프트 추가) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 기술 스택
|
||||
|
||||
- **Backend**: Nitro (H3) + PostgreSQL
|
||||
- **Frontend**: Nuxt3 + Vue3 + Bootstrap 5
|
||||
- **에디터**: Tiptap (WYSIWYG 위키 에디터)
|
||||
- **AI**: OpenAI GPT-4o-mini
|
||||
- **인증**: 기존 세션 기반 (requireAuth)
|
||||
|
||||
---
|
||||
|
||||
## 8. 향후 확장 고려
|
||||
|
||||
1. **첨부파일**: 회의자료/사진 업로드 (2단계)
|
||||
2. **알림**: TODO 담당자에게 알림 발송
|
||||
3. **반복 회의**: 정기 회의 템플릿
|
||||
4. **회의록 공유**: 참석자 이메일 발송
|
||||
5. **음성 회의록**: 녹음 파일 → 텍스트 변환 (STT)
|
||||
469
claude_temp/02_사업_프로젝트_계층구조_작업계획서.md
Normal file
469
claude_temp/02_사업_프로젝트_계층구조_작업계획서.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# 사업-프로젝트 계층 구조 작업계획서
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 예상 기간: 3~5일
|
||||
> 우선순위: 2
|
||||
|
||||
---
|
||||
|
||||
## 1. 기능 개요
|
||||
|
||||
### 1.1 핵심 컨셉
|
||||
- 프로젝트 상위에 **사업(Business)** 개념 추가
|
||||
- 사업 단위로 주간보고 취합
|
||||
- 개발자 주간보고는 기존과 동일, 취합 시 사업 단위로 묶음
|
||||
|
||||
### 1.2 계층 구조
|
||||
```
|
||||
사업 (Business)
|
||||
└─ 프로젝트 A
|
||||
└─ 프로젝트 B
|
||||
└─ 프로젝트 C
|
||||
|
||||
(사업 미지정)
|
||||
└─ 프로젝트 D
|
||||
└─ 프로젝트 E
|
||||
```
|
||||
|
||||
### 1.3 결정 사항
|
||||
|
||||
| # | 항목 | 결정 |
|
||||
|:-:|------|:----:|
|
||||
| 1 | 프로젝트-사업 관계 | **선택 (NULL 허용)** |
|
||||
| 2 | 기존 프로젝트 처리 | **그대로 유지, 필요 시 배정** |
|
||||
| 3 | 취합보고 단위 | **사업별 1개 보고서 (프로젝트별 정리)** |
|
||||
| 4 | 주간보고 작성 시 | **프로젝트 선택 → 사업 자동 표시** |
|
||||
| 5 | 사업 관리 권한 | **매니저 이상** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 모델
|
||||
|
||||
### 2.1 사업 테이블 (wr_business)
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_business (
|
||||
business_id SERIAL PRIMARY KEY,
|
||||
|
||||
-- 기본 정보
|
||||
business_code VARCHAR(20) NOT NULL UNIQUE, -- 사업 코드 (예: BIZ-2026-001)
|
||||
business_name VARCHAR(200) NOT NULL, -- 사업명
|
||||
business_description TEXT, -- 사업 설명
|
||||
|
||||
-- 기간
|
||||
start_date DATE, -- 사업 시작일
|
||||
end_date DATE, -- 사업 종료일 (예정)
|
||||
|
||||
-- 상태
|
||||
business_status VARCHAR(20) DEFAULT 'IN_PROGRESS',
|
||||
-- PLANNING: 계획중, IN_PROGRESS: 진행중, COMPLETED: 완료, ON_HOLD: 보류
|
||||
|
||||
-- 담당
|
||||
manager_id INTEGER REFERENCES wr_employee_info(employee_id), -- 사업 담당자
|
||||
|
||||
-- 메타
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_ip VARCHAR(50),
|
||||
created_email VARCHAR(100),
|
||||
updated_ip VARCHAR(50),
|
||||
updated_email VARCHAR(100)
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_business_status ON wr_business(business_status);
|
||||
CREATE INDEX idx_business_manager ON wr_business(manager_id);
|
||||
CREATE INDEX idx_business_active ON wr_business(is_active);
|
||||
```
|
||||
|
||||
### 2.2 프로젝트 테이블 수정 (wr_project_info)
|
||||
|
||||
```sql
|
||||
-- 기존 테이블에 컬럼 추가
|
||||
ALTER TABLE wr_project_info
|
||||
ADD COLUMN business_id INTEGER REFERENCES wr_business(business_id);
|
||||
|
||||
-- 인덱스 추가
|
||||
CREATE INDEX idx_project_business ON wr_project_info(business_id);
|
||||
```
|
||||
|
||||
### 2.3 사업 주간보고 테이블 (wr_business_weekly_report)
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_business_weekly_report (
|
||||
report_id SERIAL PRIMARY KEY,
|
||||
|
||||
-- 사업/주차 정보
|
||||
business_id INTEGER NOT NULL REFERENCES wr_business(business_id),
|
||||
report_year INTEGER NOT NULL,
|
||||
report_week INTEGER NOT NULL,
|
||||
week_start_date DATE NOT NULL,
|
||||
week_end_date DATE NOT NULL,
|
||||
|
||||
-- AI 취합 결과
|
||||
ai_summary TEXT, -- AI 취합 요약 (JSON)
|
||||
ai_generated_at TIMESTAMP, -- AI 생성 일시
|
||||
|
||||
-- 상태
|
||||
report_status VARCHAR(20) DEFAULT 'DRAFT', -- DRAFT: 임시, CONFIRMED: 확정
|
||||
confirmed_at TIMESTAMP,
|
||||
confirmed_by INTEGER REFERENCES wr_employee_info(employee_id),
|
||||
|
||||
-- 메타
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_ip VARCHAR(50),
|
||||
created_email VARCHAR(100),
|
||||
|
||||
-- 유니크 제약
|
||||
UNIQUE(business_id, report_year, report_week)
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_biz_report_business ON wr_business_weekly_report(business_id);
|
||||
CREATE INDEX idx_biz_report_week ON wr_business_weekly_report(report_year, report_week);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API 설계
|
||||
|
||||
### 3.1 사업 관리 API (매니저 이상)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/business/list | 사업 목록 조회 |
|
||||
| GET | /api/business/[id]/detail | 사업 상세 조회 |
|
||||
| POST | /api/business/create | 사업 생성 |
|
||||
| PUT | /api/business/[id]/update | 사업 수정 |
|
||||
| DELETE | /api/business/[id]/delete | 사업 삭제 (비활성화) |
|
||||
|
||||
### 3.2 프로젝트-사업 연결 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| PUT | /api/project/[id]/assign-business | 프로젝트에 사업 배정 |
|
||||
| GET | /api/business/[id]/projects | 사업 소속 프로젝트 목록 |
|
||||
|
||||
### 3.3 사업 주간보고 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/business/report/list | 사업 주간보고 목록 |
|
||||
| GET | /api/business/report/[id]/detail | 사업 주간보고 상세 |
|
||||
| POST | /api/business/[id]/report/generate | 사업 주간보고 AI 취합 생성 |
|
||||
| POST | /api/business/report/[id]/regenerate | AI 재생성 |
|
||||
| PUT | /api/business/report/[id]/confirm | 사업 주간보고 확정 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 설계
|
||||
|
||||
### 4.1 사업 목록 (/business)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 사업 관리 [+ 사업 등록] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 상태: [전체 ▼] 검색: [_______________] [검색] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ BIZ-2026-001 │ │
|
||||
│ │ PIMS 고도화 사업 🟢 진행중 │ │
|
||||
│ │ 담당: 조효성 | 프로젝트 3개 | 2026.01~2026.12 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ BIZ-2025-003 │ │
|
||||
│ │ NCCP 유지보수 🟢 진행중 │ │
|
||||
│ │ 담당: 서혜원 | 프로젝트 2개 | 2025.01~2025.12 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 사업 상세/수정 (/business/[id])
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 사업 상세 [수정] [삭제] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 사업코드: BIZ-2026-001 │
|
||||
│ 사업명: PIMS 고도화 사업 │
|
||||
│ 상태: 🟢 진행중 │
|
||||
│ 기간: 2026-01-01 ~ 2026-12-31 │
|
||||
│ 담당자: 조효성 │
|
||||
│ 설명: 질병관리청 PIMS 시스템 Vue3 전환 및 기능 고도화 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 📁 소속 프로젝트 (3) [+ 프로젝트 배정] │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ • 2026-001 PIMS 프론트엔드 개발 진행중 │
|
||||
│ • 2026-002 PIMS 백엔드 API 개발 진행중 │
|
||||
│ • 2026-003 PIMS DB 마이그레이션 계획중 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 📊 주간보고 현황 [취합보고 보기] │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 2026년 2주차: 3명 제출 / 3명 중 │
|
||||
│ 2026년 1주차: 3명 제출 / 3명 중 ✅ 취합완료 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 사업 주간보고 (취합) (/business/[id]/report/[year]/[week])
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 📋 PIMS 고도화 사업 - 2026년 2주차 주간보고 │
|
||||
│ [AI 재생성] [확정하기] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 기간: 2026-01-06 ~ 2026-01-12 │
|
||||
│ 상태: ⚠️ 임시 (확정 전) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ## 📊 전체 요약 │
|
||||
│ 이번 주 PIMS 고도화 사업은 프론트엔드 대시보드 개발 완료, │
|
||||
│ API 연동 80% 진행, DB 설계 검토 중입니다. │
|
||||
│ │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ ## 🔷 프로젝트별 상세 │
|
||||
│ │
|
||||
│ ### 1. PIMS 프론트엔드 개발 │
|
||||
│ **금주 실적** │
|
||||
│ - 대시보드 화면 개발 완료 (조효성, 16h) │
|
||||
│ - 주간보고 목록 UI 개선 (서혜원, 8h) │
|
||||
│ │
|
||||
│ **차주 계획** │
|
||||
│ - 취합보고 화면 개발 │
|
||||
│ - 사용자 관리 화면 개발 │
|
||||
│ │
|
||||
│ ### 2. PIMS 백엔드 API 개발 │
|
||||
│ **금주 실적** │
|
||||
│ - 인증 API 세션 방식 전환 (조효성, 8h) │
|
||||
│ - 주간보고 CRUD API 완료 (조효성, 12h) │
|
||||
│ │
|
||||
│ **이슈** │
|
||||
│ - DB 연결 타임아웃 간헐적 발생 → 커넥션 풀 설정 조정 필요 │
|
||||
│ │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ ## ⚠️ 주요 이슈 │
|
||||
│ 1. DB 연결 타임아웃 - 커넥션 풀 설정 조정 예정 │
|
||||
│ 2. 고객사 요구사항 추가 - 다음 주 협의 예정 │
|
||||
│ │
|
||||
│ ## 📅 차주 주요 일정 │
|
||||
│ - 1/15(수): 고객사 중간보고 │
|
||||
│ - 1/17(금): DB 마이그레이션 범위 확정 회의 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
* 개발자들이 작성한 주간보고를 AI가 사업/프로젝트 단위로 자동 취합
|
||||
* 별도 작성 없음, 취합만 실행
|
||||
```
|
||||
|
||||
### 4.4 프로젝트 수정 시 사업 선택
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 프로젝트 수정 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 프로젝트코드: 2026-001 │
|
||||
│ 프로젝트명: [PIMS 프론트엔드 개발_______] │
|
||||
│ 소속 사업: [PIMS 고도화 사업 ▼] ← 신규 추가 │
|
||||
│ - 선택안함 │
|
||||
│ - PIMS 고도화 사업 │
|
||||
│ - NCCP 유지보수 │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.5 주간보고 작성 시 사업 표시
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 주간보고 작성 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 프로젝트 선택: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ☑ PIMS 프론트엔드 개발 │ │
|
||||
│ │ └ 📁 PIMS 고도화 사업 ← 자동 표시 │ │
|
||||
│ │ ☐ NCCP 기능개선 │ │
|
||||
│ │ └ 📁 NCCP 유지보수 │ │
|
||||
│ │ ☐ 사내 시스템 개발 │ │
|
||||
│ │ └ 📁 (사업 미지정) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AI 프롬프트 설계
|
||||
|
||||
### 5.1 사업 주간보고 취합 프롬프트
|
||||
|
||||
```
|
||||
당신은 IT 프로젝트 주간보고 취합 전문가입니다.
|
||||
아래 개발자들의 주간보고를 사업 단위로 취합하여 정리해주세요.
|
||||
|
||||
## 사업 정보
|
||||
- 사업명: {business_name}
|
||||
- 사업코드: {business_code}
|
||||
- 보고 기간: {week_start_date} ~ {week_end_date}
|
||||
|
||||
## 소속 프로젝트
|
||||
{projects_list}
|
||||
|
||||
## 개발자 주간보고 원본
|
||||
{weekly_reports_raw}
|
||||
|
||||
## 출력 형식 (JSON)
|
||||
{
|
||||
"overall_summary": "전체 요약 (3-5문장)",
|
||||
"projects": [
|
||||
{
|
||||
"project_id": 1,
|
||||
"project_name": "프로젝트명",
|
||||
"work_summary": "금주 실적 요약",
|
||||
"work_details": [
|
||||
{"task": "작업내용", "assignee": "담당자", "hours": 8}
|
||||
],
|
||||
"plan_summary": "차주 계획 요약",
|
||||
"plan_details": ["계획1", "계획2"],
|
||||
"issues": ["이슈1", "이슈2"]
|
||||
}
|
||||
],
|
||||
"overall_issues": [
|
||||
{"issue": "이슈 내용", "action": "대응 방안"}
|
||||
],
|
||||
"next_week_schedule": [
|
||||
{"date": "1/15(수)", "event": "고객사 중간보고"}
|
||||
],
|
||||
"statistics": {
|
||||
"total_members": 3,
|
||||
"submitted_members": 3,
|
||||
"total_work_hours": 120,
|
||||
"completion_rate": 85
|
||||
}
|
||||
}
|
||||
|
||||
## 규칙
|
||||
1. 프로젝트별로 실적/계획/이슈를 구분하여 정리
|
||||
2. 동일 작업은 통합하고 담당자/시간 합산
|
||||
3. 이슈는 중요도 순으로 정렬
|
||||
4. 숫자(시간, 진척률 등)는 정확히 유지
|
||||
5. JSON 외 다른 텍스트 출력 금지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 작업 일정
|
||||
|
||||
### Phase 1: 사업 CRUD (1.5일) ✅ 완료
|
||||
- [x] 시작: 2026-01-11 00:28 KST
|
||||
- [x] 완료: 2026-01-11 00:31 KST
|
||||
- [x] 소요시간: 3분
|
||||
|
||||
**작업 내용:**
|
||||
- [x] DB 테이블 생성 (wr_business) ✅ 기존 존재
|
||||
- [x] 사업 CRUD API (매니저 이상 권한) ✅
|
||||
- [x] 사업 목록/상세/등록/수정 화면 ✅
|
||||
- [ ] 메뉴 권한 설정 ⏳ 추후
|
||||
|
||||
**생성된 파일:**
|
||||
- backend/api/business/list.get.ts
|
||||
- backend/api/business/create.post.ts
|
||||
- backend/api/business/[id]/detail.get.ts
|
||||
- backend/api/business/[id]/update.put.ts
|
||||
- backend/api/business/[id]/delete.delete.ts
|
||||
- frontend/business/index.vue
|
||||
- frontend/business/[id].vue
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 프로젝트-사업 연결 (1일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 프로젝트 테이블에 business_id 컬럼 추가
|
||||
- [ ] 프로젝트 수정 화면에 사업 선택 추가
|
||||
- [ ] 프로젝트 배정 API
|
||||
- [ ] 주간보고 작성 시 사업명 표시
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 사업 주간보고 취합 (1.5일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 사업 주간보고 테이블 생성 (wr_business_weekly_report)
|
||||
- [ ] 사업 주간보고 취합 API (AI 활용)
|
||||
- [ ] 사업 주간보고 상세 화면
|
||||
- [ ] 확정 기능
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 테스트 및 정리 (0.5일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 전체 플로우 테스트
|
||||
- [ ] 기존 취합보고와 연계 확인
|
||||
- [ ] 버그 수정
|
||||
|
||||
---
|
||||
|
||||
## 작업 완료 결과
|
||||
|
||||
### Phase별 작업 시간
|
||||
|
||||
| Phase | 작업 내용 | 시작 | 완료 | 소요시간 |
|
||||
|:-----:|----------|:----:|:----:|:--------:|
|
||||
| 1 | 사업 CRUD | 01-11 00:28 | 01-11 00:31 | 3분 ✅ |
|
||||
| 2 | 프로젝트-사업 연결 | - | - | - |
|
||||
| 3 | 사업 주간보고 취합 | - | - | - |
|
||||
| 4 | 테스트 및 정리 | - | - | - |
|
||||
| | | | **총 소요시간** | **-** |
|
||||
|
||||
---
|
||||
|
||||
### 생성/수정된 파일
|
||||
|
||||
| 구분 | 파일 | 작업 |
|
||||
|------|------|:----:|
|
||||
| **DB** | wr_business | 신규 테이블 |
|
||||
| **DB** | wr_business_weekly_report | 신규 테이블 |
|
||||
| **DB** | wr_project_info | 수정 (business_id 추가) |
|
||||
| **API** | backend/api/business/*.ts | 신규 |
|
||||
| **API** | backend/api/business/report/*.ts | 신규 |
|
||||
| **API** | backend/api/project/[id]/assign-business.put.ts | 신규 |
|
||||
| **Frontend** | frontend/pages/business/*.vue | 신규 |
|
||||
| **Frontend** | frontend/pages/project/[id].vue | 수정 |
|
||||
| **Frontend** | frontend/pages/report/weekly/write.vue | 수정 |
|
||||
| **Utils** | backend/utils/openai.ts | 수정 (프롬프트 추가) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 기술 스택
|
||||
|
||||
- **Backend**: Nitro (H3) + PostgreSQL
|
||||
- **Frontend**: Nuxt3 + Vue3 + Bootstrap 5
|
||||
- **AI**: OpenAI GPT-4o-mini (취합 요약)
|
||||
- **인증**: 기존 세션 기반 (requireAuth, requireManager)
|
||||
|
||||
---
|
||||
|
||||
## 8. 기존 기능과의 관계
|
||||
|
||||
| 기존 기능 | 변경 사항 |
|
||||
|----------|----------|
|
||||
| 주간보고 작성 | 프로젝트 옆에 사업명 표시 (읽기전용) |
|
||||
| 프로젝트 관리 | 사업 선택 필드 추가 |
|
||||
| 전체 취합보고 | 유지 (사업 주간보고와 별개) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 향후 확장 고려
|
||||
|
||||
1. **사업별 대시보드**: 진척률, 투입시간 통계
|
||||
2. **사업 일정 관리**: 마일스톤, 일정표
|
||||
3. **사업별 산출물 관리**: 문서, 결과물 링크
|
||||
4. **고객사 공유**: 사업 주간보고 외부 공유 링크
|
||||
585
claude_temp/03_유지보수_업무관리_작업계획서.md
Normal file
585
claude_temp/03_유지보수_업무관리_작업계획서.md
Normal file
@@ -0,0 +1,585 @@
|
||||
# 유지보수 업무 요청/처리 관리 작업계획서
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 예상 기간: 5~7일
|
||||
> 우선순위: 3
|
||||
|
||||
---
|
||||
|
||||
## 1. 기능 개요
|
||||
|
||||
### 1.1 핵심 컨셉
|
||||
- 기존 엑셀/구글시트로 관리하던 유지보수 업무를 시스템화
|
||||
- **AI가 다양한 양식의 파일을 파싱** → 표준 형식으로 변환
|
||||
- 파싱 결과를 사용자가 검토/수정 후 등록
|
||||
- 처리 완료/진행중 업무 → **주간보고 실적 자동 반영**
|
||||
|
||||
### 1.2 플로우
|
||||
```
|
||||
[엑셀/구글시트 파일 업로드]
|
||||
↓
|
||||
🤖 AI 파싱
|
||||
(다양한 양식 → 표준 형식)
|
||||
↓
|
||||
[검토 화면에 로드]
|
||||
(사용자가 확인/수정)
|
||||
↓
|
||||
[등록]
|
||||
↓
|
||||
[상태 관리: 미진행→진행중→완료]
|
||||
↓
|
||||
[주간보고 작성 시]
|
||||
↓
|
||||
🤖 완료/진행중 업무 → 실적 자동 작성
|
||||
(유사 실적 있으면 AI가 병합)
|
||||
```
|
||||
|
||||
### 1.3 결정 사항
|
||||
|
||||
| # | 항목 | 결정 |
|
||||
|:-:|------|:----:|
|
||||
| 1 | 데이터 소스 | 엑셀/구글시트 파일 업로드 |
|
||||
| 2 | 양식 | 통일 안됨 → AI 파싱 |
|
||||
| 3 | 파싱 결과 | 검토 화면에서 사용자 확인 후 등록 |
|
||||
| 4 | 주간보고 연계 | 완료/진행중 → 실적 자동 작성, 유사 건 병합 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 모델
|
||||
|
||||
### 2.1 유지보수 업무 테이블 (wr_maintenance_task)
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_maintenance_task (
|
||||
task_id SERIAL PRIMARY KEY,
|
||||
|
||||
-- 프로젝트/사업 연결
|
||||
project_id INTEGER REFERENCES wr_project_info(project_id),
|
||||
business_id INTEGER REFERENCES wr_business(business_id),
|
||||
|
||||
-- 요청 정보
|
||||
request_date DATE NOT NULL, -- 접수일자
|
||||
task_title VARCHAR(300) NOT NULL, -- 제목
|
||||
task_content TEXT, -- 내용
|
||||
requester_name VARCHAR(50), -- 요청자
|
||||
priority VARCHAR(20) DEFAULT 'MEDIUM', -- 우선순위: HIGH/MEDIUM/LOW
|
||||
|
||||
-- 처리 정보
|
||||
assignee_id INTEGER REFERENCES wr_employee_info(employee_id), -- 담당자
|
||||
task_status VARCHAR(20) DEFAULT 'PENDING', -- 상태
|
||||
-- PENDING: 미진행, IN_PROGRESS: 진행중, COMPLETED: 완료
|
||||
|
||||
completed_date DATE, -- 작업완료일자
|
||||
|
||||
-- 반영 여부
|
||||
is_dev_deployed BOOLEAN DEFAULT false, -- 개발서버 반영
|
||||
dev_deployed_date DATE,
|
||||
is_prod_deployed BOOLEAN DEFAULT false, -- 운영서버 반영
|
||||
prod_deployed_date DATE,
|
||||
is_customer_confirmed BOOLEAN DEFAULT false, -- 고객 확인
|
||||
customer_confirmed_date DATE,
|
||||
|
||||
-- 주간보고 연계
|
||||
linked_report_id INTEGER REFERENCES wr_weekly_report(report_id),
|
||||
linked_task_id INTEGER REFERENCES wr_weekly_report_task(task_id),
|
||||
|
||||
-- 업로드 출처
|
||||
upload_batch_id INTEGER, -- 일괄 업로드 시 배치 ID
|
||||
source_row_number INTEGER, -- 원본 파일 행 번호
|
||||
|
||||
-- 메타
|
||||
author_id INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_ip VARCHAR(50),
|
||||
created_email VARCHAR(100),
|
||||
updated_ip VARCHAR(50),
|
||||
updated_email VARCHAR(100)
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_maint_project ON wr_maintenance_task(project_id);
|
||||
CREATE INDEX idx_maint_business ON wr_maintenance_task(business_id);
|
||||
CREATE INDEX idx_maint_status ON wr_maintenance_task(task_status);
|
||||
CREATE INDEX idx_maint_assignee ON wr_maintenance_task(assignee_id);
|
||||
CREATE INDEX idx_maint_request_date ON wr_maintenance_task(request_date);
|
||||
CREATE INDEX idx_maint_completed_date ON wr_maintenance_task(completed_date);
|
||||
```
|
||||
|
||||
### 2.2 업로드 배치 테이블 (wr_maintenance_upload_batch)
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_maintenance_upload_batch (
|
||||
batch_id SERIAL PRIMARY KEY,
|
||||
|
||||
-- 파일 정보
|
||||
file_name VARCHAR(200) NOT NULL,
|
||||
file_type VARCHAR(20), -- EXCEL, GOOGLE_SHEET
|
||||
|
||||
-- 처리 결과
|
||||
total_rows INTEGER, -- 전체 행 수
|
||||
parsed_rows INTEGER, -- 파싱 성공
|
||||
registered_rows INTEGER, -- 등록 완료
|
||||
skipped_rows INTEGER, -- 스킵 (중복 등)
|
||||
|
||||
-- AI 파싱 원본
|
||||
ai_parsed_json TEXT, -- AI 파싱 결과 원본
|
||||
|
||||
-- 메타
|
||||
uploaded_by INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
|
||||
uploaded_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API 설계
|
||||
|
||||
### 3.1 유지보수 업무 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/maintenance/list | 업무 목록 조회 (필터: 상태, 기간, 프로젝트) |
|
||||
| GET | /api/maintenance/[id]/detail | 업무 상세 조회 |
|
||||
| POST | /api/maintenance/create | 업무 직접 등록 |
|
||||
| PUT | /api/maintenance/[id]/update | 업무 수정 |
|
||||
| PUT | /api/maintenance/[id]/status | 상태 변경 |
|
||||
| DELETE | /api/maintenance/[id]/delete | 업무 삭제 |
|
||||
|
||||
### 3.2 파일 업로드/AI 파싱 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | /api/maintenance/upload/parse | 파일 업로드 → AI 파싱 |
|
||||
| POST | /api/maintenance/upload/register | 파싱 결과 검토 후 일괄 등록 |
|
||||
| GET | /api/maintenance/upload/history | 업로드 이력 조회 |
|
||||
|
||||
### 3.3 통계 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/maintenance/stats/weekly | 주간 통계 (요청/처리 건수) |
|
||||
| GET | /api/maintenance/stats/by-project | 프로젝트별 통계 |
|
||||
| GET | /api/maintenance/stats/by-assignee | 담당자별 통계 |
|
||||
|
||||
### 3.4 주간보고 연계 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/maintenance/for-report | 주간보고용 업무 목록 (완료/진행중) |
|
||||
| POST | /api/maintenance/link-to-report | 업무 → 주간보고 실적 연계 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 설계
|
||||
|
||||
### 4.1 유지보수 업무 목록 (/maintenance)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 유지보수 업무 관리 [파일 업로드] [+ 직접등록] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 프로젝트: [전체 ▼] 상태: [전체 ▼] 기간: [____] ~ [____] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📊 이번 주 현황: 요청 12건 | 완료 8건 | 진행중 3건 | 미진행 1건 │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 접수일 | 제목 | 요청자 | 담당자 | 상태 | 반영 │
|
||||
│ ────────────────────────────────────────────────────────────── │
|
||||
│ 01/10 | 로그인 오류 수정 | 김담당 | 조효성 | ✅완료 | 🟢🟢🟢 │
|
||||
│ 01/09 | 보고서 양식 변경 | 박과장 | 서혜원 | 🔄진행 | 🟢⚪⚪ │
|
||||
│ 01/09 | 엑셀 다운로드 추가 | 김담당 | - | ⏳미진행| ⚪⚪⚪ │
|
||||
│ 01/08 | 대시보드 통계 오류 | 이대리 | 조효성 | ✅완료 | 🟢🟢🟢 │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
* 반영: 개발/운영/고객확인 (🟢완료 ⚪미완료)
|
||||
```
|
||||
|
||||
### 4.2 파일 업로드 → AI 파싱 (/maintenance/upload)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 유지보수 업무 파일 업로드 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 📁 엑셀 또는 구글시트 파일을 드래그하세요 │ │
|
||||
│ │ │ │
|
||||
│ │ [파일 선택] │ │
|
||||
│ │ │ │
|
||||
│ │ 지원 형식: .xlsx, .xls, .csv │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 프로젝트: [PIMS 유지보수 ▼] ← 기본 프로젝트 선택 │
|
||||
│ │
|
||||
│ [업로드 및 분석] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
↓ AI 파싱 중... ↓
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 🤖 AI 분석 결과 [전체선택] [등록] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 파일: 유지보수현황_202601.xlsx │
|
||||
│ 분석: 15건 감지 (신규 12건, 중복 3건) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ☑ #1 (신규) │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 접수일: 2026-01-10 요청자: 김담당 │ │
|
||||
│ │ 제목: [로그인 오류 수정__________________________] │ │
|
||||
│ │ 내용: [세션 만료 후 재로그인 시 오류 발생________] │ │
|
||||
│ │ 우선순위: [높음 ▼] 담당자: [조효성 ▼] 상태: [완료 ▼] │ │
|
||||
│ │ 완료일: [2026-01-10] │ │
|
||||
│ │ 반영: ☑개발 ☑운영 ☑고객확인 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ☑ #2 (신규) │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 접수일: 2026-01-09 요청자: 박과장 │ │
|
||||
│ │ 제목: [보고서 양식 변경__________________________] │ │
|
||||
│ │ ... │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ☐ #3 (⚠️ 중복 - 기존 건과 유사) │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 제목: 로그인 문제 수정 │ │
|
||||
│ │ 💡 유사 건: #1234 "로그인 오류 수정" (01/10 등록) │ │
|
||||
│ │ [병합] [별도 등록] [스킵] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [선택 항목 등록 (10건)] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 업무 상세/수정 (/maintenance/[id])
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 유지보수 업무 상세 [수정] [삭제] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 프로젝트: PIMS 유지보수 │
|
||||
│ 접수일: 2026-01-10 │
|
||||
│ 요청자: 김담당 (고객사) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 제목: 로그인 오류 수정 │
|
||||
│ 우선순위: 🔴 높음 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 내용: │
|
||||
│ 세션 만료 후 재로그인 시 "잘못된 요청입니다" 오류 발생 │
|
||||
│ 크롬 브라우저에서만 발생, IE는 정상 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 📋 처리 현황 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 담당자: 조효성 │
|
||||
│ 상태: ✅ 완료 (2026-01-10) │
|
||||
│ │
|
||||
│ ☑ 개발서버 반영 (2026-01-10) │
|
||||
│ ☑ 운영서버 반영 (2026-01-10) │
|
||||
│ ☑ 고객 확인 (2026-01-10) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 🔗 주간보고 연계 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 연계됨: 2026년 2주차 주간보고 - 조효성 │
|
||||
│ "PIMS 로그인 세션 오류 수정 (2h)" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.4 주간보고 작성 시 연계
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 💡 이번 주 처리한 유지보수 업무가 있습니다 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ☑ 로그인 오류 수정 (완료, 01/10) │
|
||||
│ → 실적 추가: "PIMS 로그인 세션 오류 수정" │
|
||||
│ → 예상 시간: [2]시간 │
|
||||
│ │
|
||||
│ ☑ 대시보드 통계 오류 (완료, 01/08) │
|
||||
│ → 실적 추가: "대시보드 월별 통계 쿼리 수정" │
|
||||
│ → 예상 시간: [1]시간 │
|
||||
│ │
|
||||
│ ☐ 보고서 양식 변경 (진행중) │
|
||||
│ → 실적 추가 안함 (진행중은 선택) │
|
||||
│ │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ ⚠️ 유사한 기존 실적이 있습니다: │
|
||||
│ │
|
||||
│ "로그인 오류 수정" ↔ 기존: "PIMS 인증 버그 수정 (1h)" │
|
||||
│ [병합 (3h로 합산)] [별도 유지] [기존 건에 추가] │
|
||||
│ │
|
||||
│ [선택 항목 실적에 추가] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.5 통계 대시보드 (/maintenance/stats)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 📊 유지보수 업무 통계 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 기간: [2026-01 ▼] 프로젝트: [전체 ▼] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 이번 달 현황 │
|
||||
│ ┌──────────┬──────────┬──────────┬──────────┐ │
|
||||
│ │ 총 요청 │ 완료 │ 진행중 │ 미진행 │ │
|
||||
│ │ 45건 │ 32건 │ 8건 │ 5건 │ │
|
||||
│ │ │ (71%) │ (18%) │ (11%) │ │
|
||||
│ └──────────┴──────────┴──────────┴──────────┘ │
|
||||
│ │
|
||||
│ 주간 추이 (최근 4주) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 12 ████████████ │ │
|
||||
│ │ 10 ██████████ ████████ │ │
|
||||
│ │ 8 ████████ ██████ ████████████ │ │
|
||||
│ │ 6 ██████ ████ ██████████ │ │
|
||||
│ │ ────────────────────────────────── │ │
|
||||
│ │ 1주차 2주차 3주차 4주차 │ │
|
||||
│ │ ■ 요청 ■ 완료 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 담당자별 처리 현황 │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ 조효성: ████████████████ 18건 │
|
||||
│ 서혜원: ████████████ 14건 │
|
||||
│ 미배정: ████ 5건 │
|
||||
│ │
|
||||
│ 평균 처리 시간: 1.8일 │
|
||||
│ SLA 준수율: 92% (24시간 내 완료 기준) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AI 프롬프트 설계
|
||||
|
||||
### 5.1 파일 파싱 프롬프트
|
||||
|
||||
```
|
||||
당신은 엑셀/CSV 파일에서 유지보수 업무 데이터를 추출하는 전문가입니다.
|
||||
다양한 양식의 파일에서 아래 표준 형식으로 데이터를 변환해주세요.
|
||||
|
||||
## 입력 데이터
|
||||
{file_content_as_text}
|
||||
|
||||
## 추출할 필드
|
||||
- request_date: 접수일자 (YYYY-MM-DD)
|
||||
- title: 제목/요약
|
||||
- content: 상세 내용
|
||||
- requester: 요청자
|
||||
- priority: 우선순위 (HIGH/MEDIUM/LOW, 긴급/높음→HIGH, 보통/일반→MEDIUM, 낮음→LOW)
|
||||
- assignee: 담당자
|
||||
- status: 상태 (PENDING/IN_PROGRESS/COMPLETED)
|
||||
- 미진행/대기/접수 → PENDING
|
||||
- 진행/진행중/처리중 → IN_PROGRESS
|
||||
- 완료/종료/해결 → COMPLETED
|
||||
- completed_date: 완료일자 (있는 경우)
|
||||
- is_dev_deployed: 개발 반영 여부 (true/false)
|
||||
- is_prod_deployed: 운영 반영 여부 (true/false)
|
||||
- is_customer_confirmed: 고객 확인 여부 (true/false)
|
||||
|
||||
## 출력 형식 (JSON)
|
||||
{
|
||||
"parsed_rows": [
|
||||
{
|
||||
"row_number": 1,
|
||||
"request_date": "2026-01-10",
|
||||
"title": "로그인 오류 수정",
|
||||
"content": "세션 만료 후 재로그인 시 오류",
|
||||
"requester": "김담당",
|
||||
"priority": "HIGH",
|
||||
"assignee": "조효성",
|
||||
"status": "COMPLETED",
|
||||
"completed_date": "2026-01-10",
|
||||
"is_dev_deployed": true,
|
||||
"is_prod_deployed": true,
|
||||
"is_customer_confirmed": true,
|
||||
"confidence": 0.95,
|
||||
"parse_notes": "정상 파싱"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total_rows": 15,
|
||||
"parsed_successfully": 14,
|
||||
"parse_failed": 1,
|
||||
"notes": "1행은 헤더로 스킵"
|
||||
}
|
||||
}
|
||||
|
||||
## 규칙
|
||||
1. 헤더 행은 자동 감지하여 스킵
|
||||
2. 빈 행은 스킵
|
||||
3. 날짜는 다양한 형식 인식 (01/10, 2026.01.10, 1월10일 등)
|
||||
4. 상태/우선순위는 유사 표현 매핑
|
||||
5. 파싱 불확실한 필드는 confidence 낮게, parse_notes에 이유 기재
|
||||
6. JSON 외 다른 텍스트 출력 금지
|
||||
```
|
||||
|
||||
### 5.2 중복/유사 건 감지 프롬프트
|
||||
|
||||
```
|
||||
당신은 유지보수 업무의 중복/유사 건을 판단하는 전문가입니다.
|
||||
|
||||
## 신규 업무
|
||||
- 제목: {new_title}
|
||||
- 내용: {new_content}
|
||||
- 접수일: {new_date}
|
||||
|
||||
## 기존 업무 목록
|
||||
{existing_tasks_json}
|
||||
|
||||
## 판단 기준
|
||||
1. 제목이 80% 이상 유사하면 중복 의심
|
||||
2. 내용의 핵심 키워드가 동일하면 중복 의심
|
||||
3. 같은 날짜에 동일 요청자가 등록한 유사 건은 중복 가능성 높음
|
||||
|
||||
## 출력 형식 (JSON)
|
||||
{
|
||||
"is_duplicate": true,
|
||||
"similar_task_id": 1234,
|
||||
"similarity_score": 0.85,
|
||||
"reason": "제목과 내용이 85% 유사, 동일 요청자",
|
||||
"recommendation": "MERGE | SKIP | REGISTER_SEPARATE"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 주간보고 실적 생성 프롬프트
|
||||
|
||||
```
|
||||
당신은 유지보수 업무를 주간보고 실적으로 변환하는 전문가입니다.
|
||||
|
||||
## 이번 주 처리 업무
|
||||
{maintenance_tasks_json}
|
||||
|
||||
## 기존 주간보고 실적
|
||||
{existing_report_tasks_json}
|
||||
|
||||
## 작업
|
||||
1. 각 유지보수 업무를 주간보고 실적 문장으로 변환
|
||||
2. 기존 실적 중 유사한 것이 있으면 병합 제안
|
||||
3. 예상 소요 시간 추정
|
||||
|
||||
## 출력 형식 (JSON)
|
||||
{
|
||||
"generated_tasks": [
|
||||
{
|
||||
"maintenance_task_id": 123,
|
||||
"report_description": "PIMS 로그인 세션 오류 수정",
|
||||
"estimated_hours": 2,
|
||||
"similar_existing": {
|
||||
"task_id": 456,
|
||||
"description": "PIMS 인증 버그 수정",
|
||||
"merge_recommendation": "MERGE | KEEP_SEPARATE",
|
||||
"merged_description": "PIMS 로그인/인증 오류 수정",
|
||||
"merged_hours": 3
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 작업 일정
|
||||
|
||||
### Phase 1: 기본 CRUD (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] DB 테이블 생성 (wr_maintenance_task, wr_maintenance_upload_batch)
|
||||
- [ ] 유지보수 업무 CRUD API
|
||||
- [ ] 업무 목록/상세/등록/수정 화면
|
||||
- [ ] 상태 변경 기능
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 파일 업로드 + AI 파싱 (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 파일 업로드 API (엑셀/CSV)
|
||||
- [ ] AI 파싱 프롬프트 구현
|
||||
- [ ] 파싱 결과 검토 화면
|
||||
- [ ] 중복 감지 로직
|
||||
- [ ] 일괄 등록 기능
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 주간보고 연계 (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 주간보고 작성 시 유지보수 업무 조회
|
||||
- [ ] AI 실적 문장 생성
|
||||
- [ ] 유사 실적 병합 기능
|
||||
- [ ] 연계 정보 저장
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 통계 + 테스트 (1일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 통계 API (주간/월간/담당자별)
|
||||
- [ ] 통계 대시보드 화면
|
||||
- [ ] 전체 테스트 및 버그 수정
|
||||
|
||||
---
|
||||
|
||||
## 작업 완료 결과
|
||||
|
||||
### Phase별 작업 시간
|
||||
|
||||
| Phase | 작업 내용 | 시작 | 완료 | 소요시간 |
|
||||
|:-----:|----------|:----:|:----:|:--------:|
|
||||
| 1 | 기본 CRUD | - | - | - |
|
||||
| 2 | 파일 업로드 + AI 파싱 | - | - | - |
|
||||
| 3 | 주간보고 연계 | - | - | - |
|
||||
| 4 | 통계 + 테스트 | - | - | - |
|
||||
| | | | **총 소요시간** | **-** |
|
||||
|
||||
---
|
||||
|
||||
### 생성/수정된 파일
|
||||
|
||||
| 구분 | 파일 | 작업 |
|
||||
|------|------|:----:|
|
||||
| **DB** | wr_maintenance_task | 신규 테이블 |
|
||||
| **DB** | wr_maintenance_upload_batch | 신규 테이블 |
|
||||
| **API** | backend/api/maintenance/*.ts | 신규 |
|
||||
| **API** | backend/api/maintenance/upload/*.ts | 신규 |
|
||||
| **API** | backend/api/maintenance/stats/*.ts | 신규 |
|
||||
| **Frontend** | frontend/pages/maintenance/*.vue | 신규 |
|
||||
| **Frontend** | frontend/pages/report/weekly/write.vue | 수정 |
|
||||
| **Utils** | backend/utils/openai.ts | 수정 (프롬프트 추가) |
|
||||
| **Utils** | backend/utils/excel-parser.ts | 신규 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 기술 스택
|
||||
|
||||
- **Backend**: Nitro (H3) + PostgreSQL
|
||||
- **Frontend**: Nuxt3 + Vue3 + Bootstrap 5
|
||||
- **파일 처리**: SheetJS (xlsx)
|
||||
- **AI**: OpenAI GPT-4o-mini (파싱, 중복감지, 실적생성)
|
||||
- **인증**: 기존 세션 기반 (requireAuth)
|
||||
|
||||
---
|
||||
|
||||
## 8. 향후 확장 고려
|
||||
|
||||
1. **구글 시트 직접 연동**: URL 입력 → API로 직접 읽기
|
||||
2. **알림 기능**: 긴급 건 등록 시 담당자 알림
|
||||
3. **SLA 관리**: 처리 기한 설정 및 초과 알림
|
||||
4. **고객 포털**: 고객이 직접 요청 등록/상태 조회
|
||||
5. **월간 보고서**: 월별 유지보수 현황 자동 생성
|
||||
529
claude_temp/04_Gmail_OAuth_로그인_작업계획서.md
Normal file
529
claude_temp/04_Gmail_OAuth_로그인_작업계획서.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# Gmail OAuth 로그인 작업계획서
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 예상 기간: 5~7일
|
||||
> 우선순위: 4
|
||||
|
||||
---
|
||||
|
||||
## 1. 기능 개요
|
||||
|
||||
### 1.1 핵심 컨셉
|
||||
- **Google OAuth + 비밀번호 인증** 모두 지원 (개발/운영 동일)
|
||||
- Gmail 주소로 기존 사용자(wr_employee_info.email) 매칭
|
||||
- 매칭 안되면 로그인 거부 → "관리자에게 문의하세요"
|
||||
- **OAuth 로그인 후 비밀번호 미설정 시 설정 안내**
|
||||
- **비밀번호 찾기**: 이름+이메일+핸드폰 매칭 → 임시 비밀번호 이메일 발송
|
||||
|
||||
### 1.2 로그인 플로우 (개발/운영 동일)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 로그인 페이지 │
|
||||
│ │
|
||||
│ [G] Google로 로그인 │
|
||||
│ [S] Synology로 로그인 ← (5번 작업 후 추가) │
|
||||
│ │
|
||||
│ ──────────── 또는 ──────────── │
|
||||
│ │
|
||||
│ 이메일: [_______________] │
|
||||
│ 비밀번호: [_______________] │
|
||||
│ │
|
||||
│ [로그인] [비밀번호 찾기] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.3 Google OAuth 로그인 플로우
|
||||
|
||||
```
|
||||
[Google로 로그인] 클릭
|
||||
↓
|
||||
Google OAuth 인증
|
||||
↓
|
||||
Gmail 주소 획득
|
||||
↓
|
||||
wr_employee_info.email 매칭?
|
||||
├─ NO → "등록되지 않은 사용자입니다. 관리자에게 문의하세요."
|
||||
└─ YES ↓
|
||||
비밀번호 설정됨?
|
||||
├─ YES → 메인 페이지로 이동
|
||||
└─ NO → 비밀번호 설정 페이지
|
||||
"비상시 로그인을 위해 비밀번호를 설정해주세요"
|
||||
```
|
||||
|
||||
### 1.4 비밀번호 찾기 플로우
|
||||
|
||||
```
|
||||
[비밀번호 찾기] 클릭
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 이름: [_______________] │
|
||||
│ 이메일: [_______________] │
|
||||
│ 핸드폰: [_______________] │
|
||||
│ │
|
||||
│ [임시 비밀번호 발송] │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
세 가지 모두 매칭되는 사용자 확인
|
||||
├─ 매칭됨 → 이메일로 임시 비밀번호 발송 → "이메일을 확인해주세요"
|
||||
└─ 불일치 → "일치하는 정보가 없습니다"
|
||||
```
|
||||
|
||||
### 1.5 결정 사항
|
||||
|
||||
| # | 항목 | 결정 |
|
||||
|:-:|------|:----:|
|
||||
| 1 | 매칭 안되는 Gmail | 로그인 거부, 관리자 문의 안내 |
|
||||
| 2 | 비밀번호 관리 | 필수 (OAuth 로그인 후 미설정 시 설정 유도) |
|
||||
| 3 | Google 계정 연결 | 1인 1계정 |
|
||||
| 4 | 환경별 로그인 | 개발/운영 동일 (OAuth + 비밀번호 모두 지원) |
|
||||
| 5 | 비밀번호 찾기 | 이름+이메일+핸드폰 매칭 → 임시 비밀번호 이메일 발송 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 모델
|
||||
|
||||
### 2.1 사용자 테이블 수정 (wr_employee_info)
|
||||
|
||||
```sql
|
||||
-- 기존 테이블에 컬럼 추가
|
||||
ALTER TABLE wr_employee_info
|
||||
ADD COLUMN password_hash VARCHAR(200), -- 비밀번호 해시
|
||||
ADD COLUMN google_id VARCHAR(100), -- Google 고유 ID (sub)
|
||||
ADD COLUMN google_email VARCHAR(100), -- Google 이메일 (확인용)
|
||||
ADD COLUMN google_linked_at TIMESTAMP, -- Google 연결 일시
|
||||
ADD COLUMN last_login_at TIMESTAMP, -- 마지막 로그인
|
||||
ADD COLUMN last_login_ip VARCHAR(50); -- 마지막 로그인 IP
|
||||
|
||||
-- 인덱스
|
||||
CREATE UNIQUE INDEX idx_employee_google_id ON wr_employee_info(google_id) WHERE google_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### 2.2 로그인 이력 테이블 (wr_login_history) - 선택
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_login_history (
|
||||
history_id SERIAL PRIMARY KEY,
|
||||
employee_id INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
|
||||
login_type VARCHAR(20) NOT NULL, -- GOOGLE, PASSWORD
|
||||
login_ip VARCHAR(50),
|
||||
user_agent VARCHAR(500),
|
||||
login_at TIMESTAMP DEFAULT NOW(),
|
||||
success BOOLEAN DEFAULT true,
|
||||
fail_reason VARCHAR(200) -- 실패 시 사유
|
||||
);
|
||||
|
||||
CREATE INDEX idx_login_history_employee ON wr_login_history(employee_id);
|
||||
CREATE INDEX idx_login_history_at ON wr_login_history(login_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API 설계
|
||||
|
||||
### 3.1 인증 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/auth/google | Google OAuth 시작 (리다이렉트) |
|
||||
| GET | /api/auth/google/callback | Google 콜백 처리 |
|
||||
| POST | /api/auth/login | 이메일/비밀번호 로그인 |
|
||||
| POST | /api/auth/logout | 로그아웃 |
|
||||
| GET | /api/auth/me | 현재 사용자 정보 |
|
||||
|
||||
### 3.2 비밀번호 관리 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | /api/auth/set-password | 비밀번호 최초 설정 (OAuth 후) |
|
||||
| PUT | /api/auth/change-password | 비밀번호 변경 (본인) |
|
||||
| POST | /api/auth/find-password | 비밀번호 찾기 (임시 비밀번호 발송) |
|
||||
| PUT | /api/admin/user/[id]/reset-password | 비밀번호 초기화 (관리자) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 설계
|
||||
|
||||
### 4.1 로그인 페이지 (/login)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 📊 주간보고 시스템 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ [G] Google로 로그인 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ [S] Synology로 로그인 │ │ ← 5번 작업 후
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ─────────────── 또는 ─────────────── │
|
||||
│ │
|
||||
│ 이메일: [_________________________] │
|
||||
│ 비밀번호: [_________________________] │
|
||||
│ │
|
||||
│ [로그인] [비밀번호 찾기] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 비밀번호 설정 페이지 (/auth/set-password)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🔐 비밀번호 설정 │
|
||||
│ │
|
||||
│ Google 계정으로 로그인되었습니다. │
|
||||
│ 비상시 로그인을 위해 비밀번호를 설정해주세요. │
|
||||
│ │
|
||||
│ 새 비밀번호: [_________________________] │
|
||||
│ 비밀번호 확인: [_________________________] │
|
||||
│ │
|
||||
│ ※ 8자 이상, 영문+숫자 조합 권장 │
|
||||
│ │
|
||||
│ [비밀번호 설정] │
|
||||
│ │
|
||||
│ [나중에 설정하기 →] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
* "나중에 설정하기" 클릭 시 메인으로 이동하지만, 다음 로그인 시 다시 안내
|
||||
```
|
||||
|
||||
### 4.3 비밀번호 찾기 페이지 (/auth/find-password)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🔑 비밀번호 찾기 │
|
||||
│ │
|
||||
│ 등록된 정보와 일치하면 이메일로 임시 비밀번호를 발송합니다. │
|
||||
│ │
|
||||
│ 이름: [_________________________] │
|
||||
│ 이메일: [_________________________] │
|
||||
│ 핸드폰: [_________________________] │
|
||||
│ │
|
||||
│ [임시 비밀번호 발송] │
|
||||
│ │
|
||||
│ [← 로그인으로] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
↓ 발송 성공 시 ↓
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ✅ 발송 완료 │
|
||||
│ │
|
||||
│ 임시 비밀번호가 이메일로 발송되었습니다. │
|
||||
│ 이메일: hyo****@company.com │
|
||||
│ │
|
||||
│ ※ 로그인 후 비밀번호를 변경해주세요. │
|
||||
│ │
|
||||
│ [로그인하러 가기] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.4 로그인 실패 시 (매칭 안됨)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ⚠️ 로그인 실패 │
|
||||
│ │
|
||||
│ "unknown@gmail.com"은 등록되지 않은 사용자입니다. │
|
||||
│ │
|
||||
│ 시스템 사용을 위해서는 관리자에게 문의하여 │
|
||||
│ 사용자 등록을 요청해주세요. │
|
||||
│ │
|
||||
│ 관리자 연락처: admin@company.com │
|
||||
│ │
|
||||
│ [다시 로그인] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.4 마이페이지 - 비밀번호 변경 (/mypage)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 마이페이지 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 👤 기본 정보 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 이름: 조효성 │
|
||||
│ 이메일: hyosung@company.com │
|
||||
│ 소속: 개발팀 │
|
||||
│ 권한: 관리자 │
|
||||
│ │
|
||||
│ 🔗 Google 연결 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 상태: ✅ 연결됨 (hyosung@gmail.com) │
|
||||
│ 연결일: 2026-01-10 │
|
||||
│ │
|
||||
│ 🔒 비밀번호 변경 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 현재 비밀번호: [_________________________] │
|
||||
│ 새 비밀번호: [_________________________] │
|
||||
│ 비밀번호 확인: [_________________________] │
|
||||
│ │
|
||||
│ [비밀번호 변경] │
|
||||
│ │
|
||||
│ 마지막 로그인: 2026-01-10 09:30 (Google) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.5 관리자 - 사용자 관리 수정 (/admin/user)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 사용자 상세 [수정] [삭제] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 기본 정보 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 이름: 조효성 │
|
||||
│ 이메일: hyosung@company.com │
|
||||
│ ... │
|
||||
│ │
|
||||
│ 🔐 계정 관리 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ Google 연결: ✅ 연결됨 (hyosung@gmail.com) [연결 해제] │
|
||||
│ 비밀번호: ******** [초기화] │
|
||||
│ 마지막 로그인: 2026-01-10 09:30 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
* [초기화] 클릭 → 임시 비밀번호 생성 → 사용자에게 전달
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 환경 설정
|
||||
|
||||
### 5.1 환경 변수
|
||||
|
||||
```env
|
||||
# .env (공통)
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=xxx
|
||||
GOOGLE_REDIRECT_URI=https://weeklyreport.company.com/api/auth/google/callback
|
||||
|
||||
# 이메일 발송 (비밀번호 찾기용)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@company.com
|
||||
SMTP_PASS=xxx
|
||||
SMTP_FROM=주간보고시스템 <noreply@company.com>
|
||||
```
|
||||
|
||||
### 5.2 개발/운영 분기 (필요 시)
|
||||
|
||||
```env
|
||||
# .env.development
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback
|
||||
|
||||
# .env.production
|
||||
GOOGLE_REDIRECT_URI=https://weeklyreport.company.com/api/auth/google/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Google OAuth 설정
|
||||
|
||||
### 6.1 Google Cloud Console 설정
|
||||
|
||||
1. **프로젝트 생성**: Google Cloud Console
|
||||
2. **OAuth 동의 화면 설정**:
|
||||
- 앱 이름: 주간보고 시스템
|
||||
- 범위: email, profile
|
||||
3. **사용자 인증 정보 생성**:
|
||||
- OAuth 2.0 클라이언트 ID
|
||||
- 승인된 리디렉션 URI: `https://weeklyreport.company.com/api/auth/google/callback`
|
||||
|
||||
### 6.2 OAuth 흐름
|
||||
|
||||
```
|
||||
1. 사용자 → [Google로 로그인] 클릭
|
||||
2. 서버 → Google 인증 페이지로 리다이렉트
|
||||
3. 사용자 → Google 계정 선택, 권한 승인
|
||||
4. Google → 콜백 URL로 리다이렉트 (code 포함)
|
||||
5. 서버 → code로 access_token 요청
|
||||
6. 서버 → access_token으로 사용자 정보 요청
|
||||
7. 서버 → email로 wr_employee_info 조회
|
||||
- 있으면: 세션 생성, 로그인 완료
|
||||
- 없으면: 에러 페이지 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 보안 고려사항
|
||||
|
||||
### 7.1 비밀번호 정책
|
||||
|
||||
| 항목 | 정책 |
|
||||
|------|------|
|
||||
| 최소 길이 | 8자 이상 |
|
||||
| 복잡도 | 영문 + 숫자 조합 권장 |
|
||||
| 해시 알고리즘 | bcrypt (salt rounds: 10) |
|
||||
| 임시 비밀번호 | 랜덤 생성 (12자, 영문+숫자+특수문자) |
|
||||
|
||||
### 7.2 세션 관리
|
||||
|
||||
| 항목 | 설정 |
|
||||
|------|------|
|
||||
| 세션 유효기간 | 24시간 (또는 설정 가능) |
|
||||
| 세션 저장소 | 기존 방식 유지 (쿠키/메모리) |
|
||||
| 동시 로그인 | 허용 (기기별 세션) |
|
||||
|
||||
### 7.3 비밀번호 찾기 이메일 템플릿
|
||||
|
||||
```
|
||||
제목: [주간보고시스템] 임시 비밀번호 안내
|
||||
|
||||
───────────────────────────────────────
|
||||
|
||||
안녕하세요, {이름}님.
|
||||
|
||||
요청하신 임시 비밀번호를 안내드립니다.
|
||||
|
||||
임시 비밀번호: {임시비밀번호}
|
||||
|
||||
보안을 위해 로그인 후 반드시 비밀번호를 변경해주세요.
|
||||
|
||||
※ 본 메일은 발신 전용입니다.
|
||||
※ 본인이 요청하지 않은 경우 관리자에게 문의해주세요.
|
||||
|
||||
───────────────────────────────────────
|
||||
주간보고시스템
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 작업 일정
|
||||
|
||||
### Phase 1: DB + 환경 설정 (1일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] wr_employee_info 컬럼 추가 (password_hash, google_id 등)
|
||||
- [ ] wr_login_history 테이블 생성 (선택)
|
||||
- [ ] 환경 변수 설정 (Google OAuth, SMTP)
|
||||
- [ ] Google Cloud Console OAuth 설정
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 비밀번호 인증 (1.5일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] bcrypt 해시 처리
|
||||
- [ ] 이메일/비밀번호 로그인 API
|
||||
- [ ] 비밀번호 변경 API
|
||||
- [ ] 비밀번호 초기화 API (관리자)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Google OAuth (1.5일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] Google OAuth 시작/콜백 API
|
||||
- [ ] 사용자 매칭 로직 (email 기준)
|
||||
- [ ] 비밀번호 미설정 시 설정 페이지 리다이렉트
|
||||
- [ ] 비밀번호 최초 설정 API
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 비밀번호 찾기 + 이메일 발송 (1일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 이메일 발송 유틸 (nodemailer)
|
||||
- [ ] 비밀번호 찾기 API (이름+이메일+핸드폰 매칭)
|
||||
- [ ] 임시 비밀번호 생성 및 발송
|
||||
- [ ] 비밀번호 찾기 페이지
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 로그인 UI + 테스트 (1일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 로그인 페이지 (OAuth + 비밀번호)
|
||||
- [ ] 비밀번호 설정 페이지
|
||||
- [ ] 로그인 실패 페이지
|
||||
- [ ] 마이페이지 비밀번호 변경 UI
|
||||
- [ ] 관리자 사용자 관리 수정 (비밀번호 초기화)
|
||||
- [ ] 전체 플로우 테스트
|
||||
|
||||
---
|
||||
|
||||
## 작업 완료 결과
|
||||
|
||||
### Phase별 작업 시간
|
||||
|
||||
| Phase | 작업 내용 | 시작 | 완료 | 소요시간 |
|
||||
|:-----:|----------|:----:|:----:|:--------:|
|
||||
| 1 | DB + 환경 설정 | - | - | - |
|
||||
| 2 | 비밀번호 인증 | - | - | - |
|
||||
| 3 | Google OAuth | - | - | - |
|
||||
| 4 | 비밀번호 찾기 + 이메일 발송 | - | - | - |
|
||||
| 5 | 로그인 UI + 테스트 | - | - | - |
|
||||
| | | | **총 소요시간** | **-** |
|
||||
|
||||
---
|
||||
|
||||
### 생성/수정된 파일
|
||||
|
||||
| 구분 | 파일 | 작업 |
|
||||
|------|------|:----:|
|
||||
| **DB** | wr_employee_info | 수정 (컬럼 추가) |
|
||||
| **DB** | wr_login_history | 신규 테이블 (선택) |
|
||||
| **API** | backend/api/auth/google.get.ts | 신규 |
|
||||
| **API** | backend/api/auth/google/callback.get.ts | 신규 |
|
||||
| **API** | backend/api/auth/login.post.ts | 수정 |
|
||||
| **API** | backend/api/auth/set-password.post.ts | 신규 |
|
||||
| **API** | backend/api/auth/change-password.put.ts | 신규 |
|
||||
| **API** | backend/api/auth/find-password.post.ts | 신규 |
|
||||
| **API** | backend/api/admin/user/[id]/reset-password.put.ts | 신규 |
|
||||
| **Frontend** | frontend/pages/login.vue | 수정 |
|
||||
| **Frontend** | frontend/pages/auth/set-password.vue | 신규 |
|
||||
| **Frontend** | frontend/pages/auth/find-password.vue | 신규 |
|
||||
| **Frontend** | frontend/pages/mypage.vue | 수정 |
|
||||
| **Frontend** | frontend/pages/admin/user/[id].vue | 수정 |
|
||||
| **Utils** | backend/utils/password.ts | 신규 |
|
||||
| **Utils** | backend/utils/email.ts | 신규 |
|
||||
| **Config** | .env | 수정 (OAuth, SMTP 설정) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 기술 스택
|
||||
|
||||
- **Backend**: Nitro (H3) + PostgreSQL
|
||||
- **Frontend**: Nuxt3 + Vue3 + Bootstrap 5
|
||||
- **OAuth**: Google OAuth 2.0
|
||||
- **비밀번호**: bcrypt
|
||||
- **이메일 발송**: nodemailer
|
||||
- **세션**: 기존 방식 유지
|
||||
|
||||
---
|
||||
|
||||
## 10. 향후 확장 고려
|
||||
|
||||
1. **Synology SSO 연동**: 다음 작업 (5번)
|
||||
2. **2단계 인증 (2FA)**: TOTP 기반 추가 인증
|
||||
3. **소셜 로그인 확장**: Microsoft, Kakao 등
|
||||
4. **비밀번호 만료**: 90일 주기 변경 강제
|
||||
5. **로그인 알림**: 새 기기 로그인 시 이메일 알림
|
||||
6. **임시 비밀번호 만료**: 24시간 후 만료 처리
|
||||
557
claude_temp/05_Synology_SSO_연동_작업계획서.md
Normal file
557
claude_temp/05_Synology_SSO_연동_작업계획서.md
Normal file
@@ -0,0 +1,557 @@
|
||||
# Synology SSO 연동 작업계획서
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 예상 기간: 2~3일
|
||||
> 우선순위: 5
|
||||
> 선행 작업: 4번 (Gmail OAuth 로그인)
|
||||
|
||||
---
|
||||
|
||||
## 1. 기능 개요
|
||||
|
||||
### 1.1 핵심 컨셉
|
||||
- 사내 Synology NAS의 SSO Server를 통한 로그인
|
||||
- **4번(Gmail OAuth)과 동일한 구조**로 구현
|
||||
- Google OAuth와 병행 사용 가능 (사용자 선택)
|
||||
- Synology 계정 이메일로 기존 사용자 매칭
|
||||
|
||||
### 1.2 로그인 페이지
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 📊 주간보고 시스템 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ [G] Google로 로그인 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ [S] Synology로 로그인 │ │ ← 이번 작업
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ─────────────── 또는 ─────────────── │
|
||||
│ │
|
||||
│ 이메일: [_________________________] │
|
||||
│ 비밀번호: [_________________________] │
|
||||
│ │
|
||||
│ [로그인] [비밀번호 찾기] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.3 Synology SSO 로그인 플로우
|
||||
|
||||
```
|
||||
[Synology로 로그인] 클릭
|
||||
↓
|
||||
Synology SSO Server OAuth 인증
|
||||
↓
|
||||
Synology 계정 이메일 획득
|
||||
↓
|
||||
wr_employee_info.email 매칭?
|
||||
├─ NO → "등록되지 않은 사용자입니다. 관리자에게 문의하세요."
|
||||
└─ YES ↓
|
||||
비밀번호 설정됨?
|
||||
├─ YES → 메인 페이지로 이동
|
||||
└─ NO → 비밀번호 설정 페이지
|
||||
"비상시 로그인을 위해 비밀번호를 설정해주세요"
|
||||
```
|
||||
|
||||
### 1.4 결정 사항
|
||||
|
||||
| # | 항목 | 결정 |
|
||||
|:-:|------|:----:|
|
||||
| 1 | 매칭 기준 | Synology 계정 이메일 ↔ wr_employee_info.email |
|
||||
| 2 | 매칭 안됨 | 로그인 거부, 관리자 문의 안내 |
|
||||
| 3 | 비밀번호 미설정 | 4번과 동일하게 설정 유도 |
|
||||
| 4 | Google/Synology 동시 연결 | 허용 (1인 1계정씩) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Synology SSO Server 개요
|
||||
|
||||
### 2.1 SSO Server란?
|
||||
- Synology NAS에서 제공하는 OAuth 2.0 기반 인증 서버
|
||||
- DSM (DiskStation Manager) 패키지로 설치
|
||||
- 사내 NAS 계정으로 외부 애플리케이션 로그인 가능
|
||||
|
||||
### 2.2 OAuth 2.0 흐름 (Google과 동일)
|
||||
|
||||
```
|
||||
1. 사용자 → [Synology로 로그인] 클릭
|
||||
2. 서버 → Synology SSO 인증 페이지로 리다이렉트
|
||||
3. 사용자 → Synology 계정 로그인, 권한 승인
|
||||
4. Synology → 콜백 URL로 리다이렉트 (code 포함)
|
||||
5. 서버 → code로 access_token 요청
|
||||
6. 서버 → access_token으로 사용자 정보 요청
|
||||
7. 서버 → email로 wr_employee_info 조회
|
||||
- 있으면: 세션 생성, 로그인 완료
|
||||
- 없으면: 에러 페이지 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 모델
|
||||
|
||||
### 3.1 사용자 테이블 수정 (wr_employee_info)
|
||||
|
||||
```sql
|
||||
-- 4번 작업에서 추가한 컬럼에 Synology 관련 컬럼 추가
|
||||
ALTER TABLE wr_employee_info
|
||||
ADD COLUMN synology_id VARCHAR(100), -- Synology 고유 ID
|
||||
ADD COLUMN synology_username VARCHAR(100), -- Synology 사용자명
|
||||
ADD COLUMN synology_linked_at TIMESTAMP; -- Synology 연결 일시
|
||||
|
||||
-- 인덱스
|
||||
CREATE UNIQUE INDEX idx_employee_synology_id ON wr_employee_info(synology_id) WHERE synology_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### 3.2 최종 사용자 테이블 구조 (4번 + 5번)
|
||||
|
||||
```sql
|
||||
-- wr_employee_info 인증 관련 컬럼 요약
|
||||
employee_id SERIAL PRIMARY KEY,
|
||||
...
|
||||
-- 비밀번호 (4번)
|
||||
password_hash VARCHAR(200),
|
||||
|
||||
-- Google OAuth (4번)
|
||||
google_id VARCHAR(100),
|
||||
google_email VARCHAR(100),
|
||||
google_linked_at TIMESTAMP,
|
||||
|
||||
-- Synology SSO (5번)
|
||||
synology_id VARCHAR(100),
|
||||
synology_username VARCHAR(100),
|
||||
synology_linked_at TIMESTAMP,
|
||||
|
||||
-- 공통
|
||||
last_login_at TIMESTAMP,
|
||||
last_login_ip VARCHAR(50),
|
||||
last_login_type VARCHAR(20), -- PASSWORD, GOOGLE, SYNOLOGY
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 설계
|
||||
|
||||
### 4.1 Synology SSO API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/auth/synology | Synology OAuth 시작 (리다이렉트) |
|
||||
| GET | /api/auth/synology/callback | Synology 콜백 처리 |
|
||||
|
||||
### 4.2 기존 API와 통합
|
||||
|
||||
| Method | Endpoint | 설명 | 비고 |
|
||||
|--------|----------|------|------|
|
||||
| GET | /api/auth/me | 현재 사용자 정보 | login_type 포함 |
|
||||
| POST | /api/auth/logout | 로그아웃 | 공통 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Synology SSO Server 설정 가이드
|
||||
|
||||
### 5.1 SSO Server 패키지 설치
|
||||
|
||||
1. **DSM 접속**: `https://nas.company.com:5001` 관리자 계정으로 로그인
|
||||
2. **패키지 센터** 열기
|
||||
3. **"SSO Server"** 검색 → **설치**
|
||||
4. 설치 완료 후 **열기**
|
||||
|
||||
```
|
||||
패키지 센터 > 검색: "SSO Server" > [설치]
|
||||
```
|
||||
|
||||
### 5.2 SSO Server 기본 설정
|
||||
|
||||
1. **SSO Server** 앱 열기
|
||||
2. **설정** 탭 진입
|
||||
3. 기본 설정 확인:
|
||||
|
||||
| 설정 항목 | 권장 값 |
|
||||
|----------|---------|
|
||||
| SSO 서비스 활성화 | ✅ 체크 |
|
||||
| HTTPS 사용 | ✅ 체크 (필수) |
|
||||
| 포트 | 5001 (기본값) |
|
||||
|
||||
### 5.3 애플리케이션 등록
|
||||
|
||||
1. **SSO Server** > **애플리케이션** 탭
|
||||
2. **[추가]** 버튼 클릭
|
||||
3. 아래 정보 입력:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 애플리케이션 추가 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 애플리케이션 이름: [주간보고시스템________________] │
|
||||
│ │
|
||||
│ 리디렉션 URI: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ https://weeklyreport.company.com/api/auth/synology/callback │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ※ 개발용 추가 (선택): │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ http://localhost:3000/api/auth/synology/callback │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [저장] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
4. **저장** 후 **Client ID / Client Secret** 확인 및 복사
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 애플리케이션 정보 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 애플리케이션 이름: 주간보고시스템 │
|
||||
│ │
|
||||
│ Client ID: abc123def456... [복사] │
|
||||
│ Client Secret: xyz789ghi012... [복사] │
|
||||
│ │
|
||||
│ ※ Client Secret은 다시 볼 수 없으니 반드시 복사해두세요! │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.4 사용자 권한 설정
|
||||
|
||||
1. **SSO Server** > **권한** 탭
|
||||
2. 로그인 허용할 사용자/그룹 선택
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 권한 설정 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 애플리케이션: 주간보고시스템 │
|
||||
│ │
|
||||
│ 허용된 사용자/그룹: │
|
||||
│ ☑ administrators │
|
||||
│ ☑ developers │
|
||||
│ ☑ users │
|
||||
│ ☐ guests │
|
||||
│ │
|
||||
│ ※ 또는 [모든 사용자 허용] 선택 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.5 HTTPS/SSL 인증서 확인
|
||||
|
||||
**제어판 > 보안 > 인증서**에서 유효한 SSL 인증서 확인
|
||||
|
||||
| 확인 항목 | 필수 여부 |
|
||||
|----------|:--------:|
|
||||
| 유효한 SSL 인증서 | ✅ 필수 |
|
||||
| 인증서 만료일 확인 | ✅ 확인 |
|
||||
| Let's Encrypt 또는 정식 인증서 | 권장 |
|
||||
|
||||
```
|
||||
※ 자체 서명 인증서(Self-signed)는 브라우저 경고 발생할 수 있음
|
||||
※ Let's Encrypt 무료 인증서 권장
|
||||
```
|
||||
|
||||
### 5.6 방화벽/포트 설정
|
||||
|
||||
외부에서 NAS 접근이 필요한 경우:
|
||||
|
||||
| 포트 | 용도 | 필요 여부 |
|
||||
|:----:|------|:--------:|
|
||||
| 5001 | DSM HTTPS | ✅ 필수 |
|
||||
| 443 | 역방향 프록시 사용 시 | 선택 |
|
||||
|
||||
```
|
||||
※ 공유기/방화벽에서 5001 포트 개방 필요
|
||||
※ 또는 역방향 프록시로 443 → 5001 연결
|
||||
```
|
||||
|
||||
### 5.7 설정 완료 체크리스트
|
||||
|
||||
| # | 항목 | 확인 |
|
||||
|:-:|------|:----:|
|
||||
| 1 | SSO Server 패키지 설치 | ☐ |
|
||||
| 2 | SSO 서비스 활성화 | ☐ |
|
||||
| 3 | 애플리케이션 등록 (주간보고시스템) | ☐ |
|
||||
| 4 | Client ID 복사 | ☐ |
|
||||
| 5 | Client Secret 복사 | ☐ |
|
||||
| 6 | 리디렉션 URI 설정 (운영) | ☐ |
|
||||
| 7 | 리디렉션 URI 설정 (개발) - 선택 | ☐ |
|
||||
| 8 | 사용자/그룹 권한 설정 | ☐ |
|
||||
| 9 | SSL 인증서 유효 확인 | ☐ |
|
||||
| 10 | 외부 접근 테스트 | ☐ |
|
||||
|
||||
### 5.8 설정 후 .env 파일 업데이트
|
||||
|
||||
```env
|
||||
# Synology SSO (5.3에서 복사한 값 입력)
|
||||
SYNOLOGY_SSO_URL=https://nas.company.com:5001
|
||||
SYNOLOGY_CLIENT_ID=여기에_Client_ID_붙여넣기
|
||||
SYNOLOGY_CLIENT_SECRET=여기에_Client_Secret_붙여넣기
|
||||
SYNOLOGY_REDIRECT_URI=https://weeklyreport.company.com/api/auth/synology/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 상세
|
||||
|
||||
### 6.1 Synology OAuth 엔드포인트
|
||||
|
||||
```
|
||||
# 인증 요청 (Authorization)
|
||||
GET https://{SYNOLOGY_SSO_URL}/webman/sso/SSOOauth.cgi
|
||||
?response_type=code
|
||||
&client_id={CLIENT_ID}
|
||||
&redirect_uri={REDIRECT_URI}
|
||||
&scope=user_id
|
||||
|
||||
# 토큰 요청 (Token)
|
||||
POST https://{SYNOLOGY_SSO_URL}/webman/sso/SSOAccessToken.cgi
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code
|
||||
&code={CODE}
|
||||
&client_id={CLIENT_ID}
|
||||
&client_secret={CLIENT_SECRET}
|
||||
&redirect_uri={REDIRECT_URI}
|
||||
|
||||
# 사용자 정보 요청 (UserInfo)
|
||||
GET https://{SYNOLOGY_SSO_URL}/webman/sso/SSOUserInfo.cgi
|
||||
?access_token={ACCESS_TOKEN}
|
||||
```
|
||||
|
||||
### 6.2 사용자 정보 응답 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"email": "hyosung@company.com",
|
||||
"user_id": 1001,
|
||||
"user_name": "hyosung"
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 API 구현 코드 (예시)
|
||||
|
||||
```typescript
|
||||
// backend/api/auth/synology.get.ts
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const authUrl = new URL(`${config.synologySsoUrl}/webman/sso/SSOOauth.cgi`);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', config.synologyClientId);
|
||||
authUrl.searchParams.set('redirect_uri', config.synologyRedirectUri);
|
||||
authUrl.searchParams.set('scope', 'user_id');
|
||||
|
||||
return sendRedirect(event, authUrl.toString());
|
||||
});
|
||||
|
||||
// backend/api/auth/synology/callback.get.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const code = query.code as string;
|
||||
|
||||
if (!code) {
|
||||
return sendRedirect(event, '/login?error=no_code');
|
||||
}
|
||||
|
||||
// 1. Access Token 요청
|
||||
const tokenResponse = await $fetch(`${config.synologySsoUrl}/webman/sso/SSOAccessToken.cgi`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: config.synologyClientId,
|
||||
client_secret: config.synologyClientSecret,
|
||||
redirect_uri: config.synologyRedirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
// 2. 사용자 정보 요청
|
||||
const userInfo = await $fetch(`${config.synologySsoUrl}/webman/sso/SSOUserInfo.cgi`, {
|
||||
params: { access_token: tokenResponse.access_token },
|
||||
});
|
||||
|
||||
// 3. 이메일로 사용자 매칭
|
||||
const employee = await findEmployeeByEmail(userInfo.data.email);
|
||||
|
||||
if (!employee) {
|
||||
return sendRedirect(event, '/login?error=not_registered');
|
||||
}
|
||||
|
||||
// 4. Synology 정보 업데이트
|
||||
await updateEmployeeSynologyInfo(employee.employee_id, {
|
||||
synology_id: userInfo.data.user_id,
|
||||
synology_username: userInfo.data.user_name,
|
||||
});
|
||||
|
||||
// 5. 세션 생성
|
||||
await createSession(event, employee, 'SYNOLOGY');
|
||||
|
||||
// 6. 비밀번호 설정 여부 확인
|
||||
if (!employee.password_hash) {
|
||||
return sendRedirect(event, '/auth/set-password');
|
||||
}
|
||||
|
||||
return sendRedirect(event, '/');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 화면 설계
|
||||
|
||||
### 7.1 로그인 페이지 수정 (/login)
|
||||
|
||||
4번 작업에서 만든 로그인 페이지에 Synology 버튼 추가
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="login-buttons">
|
||||
<!-- Google 로그인 -->
|
||||
<a href="/api/auth/google" class="btn btn-outline-danger btn-lg w-100 mb-2">
|
||||
<i class="bi bi-google me-2"></i> Google로 로그인
|
||||
</a>
|
||||
|
||||
<!-- Synology 로그인 -->
|
||||
<a href="/api/auth/synology" class="btn btn-outline-primary btn-lg w-100 mb-3">
|
||||
<i class="bi bi-hdd-network me-2"></i> Synology로 로그인
|
||||
</a>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<!-- 이메일/비밀번호 로그인 -->
|
||||
...
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 7.2 마이페이지 수정 (/mypage)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 마이페이지 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 👤 기본 정보 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 이름: 조효성 │
|
||||
│ 이메일: hyosung@company.com │
|
||||
│ ... │
|
||||
│ │
|
||||
│ 🔗 외부 계정 연결 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Google: ✅ 연결됨 (hyosung@gmail.com) [연결 해제] │
|
||||
│ 연결일: 2026-01-10 │
|
||||
│ │
|
||||
│ Synology: ✅ 연결됨 (hyosung) [연결 해제] │
|
||||
│ 연결일: 2026-01-10 │
|
||||
│ │
|
||||
│ 🔒 비밀번호 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ ... │
|
||||
│ │
|
||||
│ 마지막 로그인: 2026-01-10 09:30 (Synology) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 작업 일정
|
||||
|
||||
### Phase 1: Synology SSO 설정 + API (1.5일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] Synology SSO Server 애플리케이션 등록
|
||||
- [ ] wr_employee_info 컬럼 추가 (synology_id 등)
|
||||
- [ ] 환경 변수 설정
|
||||
- [ ] Synology OAuth 시작/콜백 API
|
||||
- [ ] 사용자 매칭 로직
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: UI + 테스트 (1일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 로그인 페이지에 Synology 버튼 추가
|
||||
- [ ] 마이페이지 외부 계정 연결 표시
|
||||
- [ ] 로그인 이력에 login_type 기록
|
||||
- [ ] 전체 플로우 테스트
|
||||
|
||||
---
|
||||
|
||||
## 작업 완료 결과
|
||||
|
||||
### Phase별 작업 시간
|
||||
|
||||
| Phase | 작업 내용 | 시작 | 완료 | 소요시간 |
|
||||
|:-----:|----------|:----:|:----:|:--------:|
|
||||
| 1 | Synology SSO 설정 + API | - | - | - |
|
||||
| 2 | UI + 테스트 | - | - | - |
|
||||
| | | | **총 소요시간** | **-** |
|
||||
|
||||
---
|
||||
|
||||
### 생성/수정된 파일
|
||||
|
||||
| 구분 | 파일 | 작업 |
|
||||
|------|------|:----:|
|
||||
| **DB** | wr_employee_info | 수정 (synology 컬럼 추가) |
|
||||
| **API** | backend/api/auth/synology.get.ts | 신규 |
|
||||
| **API** | backend/api/auth/synology/callback.get.ts | 신규 |
|
||||
| **Frontend** | frontend/pages/login.vue | 수정 |
|
||||
| **Frontend** | frontend/pages/mypage.vue | 수정 |
|
||||
| **Config** | .env | 수정 (Synology 설정 추가) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 기술 스택
|
||||
|
||||
- **Backend**: Nitro (H3) + PostgreSQL
|
||||
- **Frontend**: Nuxt3 + Vue3 + Bootstrap 5
|
||||
- **OAuth**: Synology SSO Server (OAuth 2.0)
|
||||
- **세션**: 기존 방식 유지 (4번과 공유)
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
### 10.1 Synology SSO Server 요구사항
|
||||
- DSM 7.0 이상 권장
|
||||
- SSO Server 패키지 설치 필요
|
||||
- HTTPS 필수 (유효한 SSL 인증서)
|
||||
|
||||
### 10.2 네트워크 설정
|
||||
- 외부에서 Synology NAS 접근 가능해야 함
|
||||
- 방화벽에서 5001 포트 (HTTPS) 허용 필요
|
||||
- 또는 역방향 프록시 설정
|
||||
|
||||
### 10.3 개발 환경 제약
|
||||
- localhost 콜백이 안 될 수 있음
|
||||
- ngrok 또는 개발용 도메인 필요할 수 있음
|
||||
|
||||
---
|
||||
|
||||
## 11. 향후 확장 고려
|
||||
|
||||
1. **LDAP 연동**: Synology LDAP Server와 통합
|
||||
2. **그룹 기반 권한**: Synology 그룹 → 시스템 권한 매핑
|
||||
3. **자동 사용자 생성**: Synology 계정 → 자동 사용자 등록 (관리자 승인)
|
||||
545
claude_temp/06_구글그룹_연동_작업계획서.md
Normal file
545
claude_temp/06_구글그룹_연동_작업계획서.md
Normal file
@@ -0,0 +1,545 @@
|
||||
# 구글 그룹 연동 작업계획서
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 예상 기간: 1~2주
|
||||
> 우선순위: 6
|
||||
> 선행 작업: 4번 (Gmail OAuth 로그인)
|
||||
|
||||
---
|
||||
|
||||
## 1. 기능 개요
|
||||
|
||||
### 1.1 핵심 컨셉
|
||||
- 사용자가 **Google OAuth로 로그인한 상태**에서
|
||||
- 본인이 속한 **Google 그룹의 게시물 조회** (가져오기)
|
||||
- 본인 **주간보고를 Google 그룹에 게시** (등록)
|
||||
- 모두 **그룹 멤버 권한**으로 동작 (관리자 권한 불필요)
|
||||
|
||||
### 1.2 권한 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Google 그룹: developers@company.com │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 멤버들: │
|
||||
│ ├─ hyosung@company.com (시스템 관리자이기도 함) │
|
||||
│ ├─ hyewon@company.com │
|
||||
│ ├─ gildong@company.com │
|
||||
│ └─ ... │
|
||||
│ │
|
||||
│ 멤버라면 누구나: │
|
||||
│ ✅ 그룹 게시물 읽기 │
|
||||
│ ✅ 그룹에 글 게시 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
※ 시스템 관리자 ≠ 그룹 관리자
|
||||
※ 시스템 관리자도 그냥 그룹 멤버로서 읽기/쓰기
|
||||
```
|
||||
|
||||
### 1.3 기능 요약
|
||||
|
||||
| 기능 | 누가 | 설명 |
|
||||
|------|------|------|
|
||||
| **가져오기** | 그룹 멤버 | 그룹 게시물 목록/내용 조회 |
|
||||
| **등록** | 그룹 멤버 | 본인 주간보고 → 그룹에 게시 |
|
||||
|
||||
### 1.4 결정 사항
|
||||
|
||||
| # | 항목 | 결정 |
|
||||
|:-:|------|:----:|
|
||||
| 1 | 권한 | 그룹 멤버 권한만 사용 |
|
||||
| 2 | 가져오기 | 본인이 속한 그룹 게시물 조회 |
|
||||
| 3 | 등록 | Gmail API로 그룹 이메일에 발송 |
|
||||
| 4 | OAuth 연계 | 4번 작업(Gmail OAuth) 토큰 활용 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 기능 상세
|
||||
|
||||
### 2.1 가져오기 (그룹 게시물 조회)
|
||||
|
||||
```
|
||||
[주간보고시스템]
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 📬 Google 그룹 게시물 [새로고침] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 그룹 선택: [developers@company.com ▼] │
|
||||
│ ───────────────────────── │
|
||||
│ developers@company.com │
|
||||
│ team-leads@company.com │
|
||||
│ all@company.com │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📧 [주간보고] 2026년 2주차 - 서혜원 │
|
||||
│ hyewon@company.com · 2026-01-10 09:30 │
|
||||
│ │
|
||||
│ 📧 [공지] 이번 주 회의 일정 변경 │
|
||||
│ gildong@company.com · 2026-01-09 14:00 │
|
||||
│ │
|
||||
│ 📧 [주간보고] 2026년 2주차 - 홍길동 │
|
||||
│ gildong@company.com · 2026-01-09 10:00 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**사용 API**: Gmail API (그룹 이메일 검색)
|
||||
```
|
||||
GET /gmail/v1/users/me/messages?q=list:developers@company.com
|
||||
```
|
||||
|
||||
### 2.2 등록 (주간보고 → 그룹 게시)
|
||||
|
||||
```
|
||||
[주간보고 상세]
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 2026년 2주차 주간보고 - 조효성 [수정] [삭제] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 금주 업무: │
|
||||
│ - 로그인 기능 개발 완료 │
|
||||
│ - 사용자 관리 페이지 수정 │
|
||||
│ ... │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 🔗 Google 그룹 공유 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 공유할 그룹: [developers@company.com ▼] │
|
||||
│ │
|
||||
│ [📤 그룹에 공유하기] │
|
||||
│ │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 공유 이력: │
|
||||
│ ✅ developers@company.com · 2026-01-10 09:30 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**발송되는 이메일 형식**:
|
||||
```
|
||||
From: hyosung@company.com (본인)
|
||||
To: developers@company.com (그룹)
|
||||
Subject: [주간보고] 2026년 2주차 - 조효성
|
||||
|
||||
────────────────────────────────────────
|
||||
2026년 2주차 주간보고
|
||||
작성자: 조효성
|
||||
소속: 개발팀
|
||||
────────────────────────────────────────
|
||||
|
||||
[금주 업무]
|
||||
- 로그인 기능 개발 완료
|
||||
- 사용자 관리 페이지 수정
|
||||
...
|
||||
|
||||
[차주 계획]
|
||||
...
|
||||
|
||||
────────────────────────────────────────
|
||||
※ 주간보고시스템에서 발송됨
|
||||
```
|
||||
|
||||
**사용 API**: Gmail API (메일 발송)
|
||||
```
|
||||
POST /gmail/v1/users/me/messages/send
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 모델
|
||||
|
||||
### 3.1 그룹 공유 이력 테이블
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_report_group_share (
|
||||
share_id SERIAL PRIMARY KEY,
|
||||
report_id INTEGER NOT NULL REFERENCES wr_weekly_report(report_id),
|
||||
employee_id INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
|
||||
group_email VARCHAR(200) NOT NULL, -- developers@company.com
|
||||
gmail_message_id VARCHAR(100), -- Gmail 메시지 ID
|
||||
shared_at TIMESTAMP DEFAULT NOW(),
|
||||
share_status VARCHAR(20) DEFAULT 'SENT', -- SENT, FAILED
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_report_share_report ON wr_report_group_share(report_id);
|
||||
CREATE INDEX idx_report_share_employee ON wr_report_group_share(employee_id);
|
||||
```
|
||||
|
||||
### 3.2 사용자 테이블 - OAuth 토큰 저장 (4번 작업 확장)
|
||||
|
||||
```sql
|
||||
-- 4번 작업에서 추가 필요
|
||||
ALTER TABLE wr_employee_info
|
||||
ADD COLUMN google_access_token TEXT,
|
||||
ADD COLUMN google_refresh_token TEXT,
|
||||
ADD COLUMN google_token_expires_at TIMESTAMP;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 설계
|
||||
|
||||
### 4.1 그룹 관련 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/google-group/my-groups | 내가 속한 그룹 목록 |
|
||||
| GET | /api/google-group/[groupEmail]/messages | 그룹 게시물 목록 |
|
||||
| GET | /api/google-group/message/[messageId] | 게시물 상세 |
|
||||
| POST | /api/google-group/share | 주간보고 그룹에 공유 |
|
||||
| GET | /api/report/[id]/share-history | 공유 이력 조회 |
|
||||
|
||||
### 4.2 API 상세
|
||||
|
||||
#### GET /api/google-group/my-groups
|
||||
```json
|
||||
// Response
|
||||
{
|
||||
"groups": [
|
||||
{ "email": "developers@company.com", "name": "개발팀" },
|
||||
{ "email": "all@company.com", "name": "전체" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/google-group/share
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"reportId": 123,
|
||||
"groupEmail": "developers@company.com"
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
"success": true,
|
||||
"messageId": "18d1234567890abc",
|
||||
"sharedAt": "2026-01-10T09:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. OAuth Scope 설정
|
||||
|
||||
### 5.1 필요한 Scope (4번 작업 확장)
|
||||
|
||||
| Scope | 용도 |
|
||||
|-------|------|
|
||||
| `openid` | 기본 인증 |
|
||||
| `email` | 이메일 주소 |
|
||||
| `profile` | 프로필 정보 |
|
||||
| **`https://www.googleapis.com/auth/gmail.readonly`** | 그룹 게시물 조회 |
|
||||
| **`https://www.googleapis.com/auth/gmail.send`** | 그룹에 메일 발송 |
|
||||
| **`https://www.googleapis.com/auth/gmail.labels`** | 라벨 조회 (선택) |
|
||||
|
||||
### 5.2 Google Cloud Console 설정 변경
|
||||
|
||||
1. **OAuth 동의 화면** > **범위 추가**
|
||||
2. Gmail API 관련 scope 추가
|
||||
3. **민감한 범위**로 분류되어 Google 검토 필요할 수 있음
|
||||
|
||||
```
|
||||
⚠️ 주의: Gmail API scope는 "민감한 범위"로 분류됨
|
||||
- 앱 인증 필요할 수 있음 (내부용은 대체로 OK)
|
||||
- Google Workspace 도메인 내 사용 시 관리자 승인으로 해결
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 화면 설계
|
||||
|
||||
### 6.1 메뉴 추가
|
||||
|
||||
```
|
||||
사이드바 메뉴:
|
||||
├─ 📊 대시보드
|
||||
├─ 📝 주간보고
|
||||
│ ├─ 작성
|
||||
│ ├─ 목록
|
||||
│ └─ 통계
|
||||
├─ 📬 Google 그룹 ← 신규
|
||||
│ └─ 게시물 조회
|
||||
├─ 👥 사용자 관리
|
||||
└─ ⚙️ 설정
|
||||
```
|
||||
|
||||
### 6.2 그룹 게시물 조회 (/google-group)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 📬 Google 그룹 게시물 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 그룹: [developers@company.com ▼] [새로고침] │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────────────│
|
||||
│ □ 제목 보낸 사람 날짜 │
|
||||
│ ───────────────────────────────────────────────────────────────│
|
||||
│ ☐ [주간보고] 2026년 2주차 - 서혜원 서혜원 01-10 09:30 │
|
||||
│ ☐ [공지] 회의 일정 변경 홍길동 01-09 14:00 │
|
||||
│ ☐ [주간보고] 2026년 2주차 - 홍길동 홍길동 01-09 10:00 │
|
||||
│ ☐ [주간보고] 2026년 1주차 - 서혜원 서혜원 01-03 09:00 │
|
||||
│ ... │
|
||||
│ │
|
||||
│ [1] [2] [3] ... [10] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
※ Google OAuth 미연결 시:
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Google 계정 연결이 필요합니다. │
|
||||
│ │
|
||||
│ [Google 계정 연결하기] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 주간보고 상세 - 그룹 공유 섹션
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 2026년 2주차 주간보고 [수정] [삭제] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ (주간보고 내용...) │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📤 Google 그룹 공유 │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 그룹 선택: [developers@company.com ▼] [공유하기] │
|
||||
│ │
|
||||
│ 공유 이력: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ✅ developers@company.com 2026-01-10 09:30 │ │
|
||||
│ │ ✅ team-leads@company.com 2026-01-10 09:35 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 구현 상세
|
||||
|
||||
### 7.1 그룹 목록 조회
|
||||
|
||||
Gmail API로는 직접 그룹 목록 조회가 어려움.
|
||||
대안:
|
||||
1. **사용자가 직접 입력** (간단)
|
||||
2. **시스템 설정에서 그룹 목록 관리** (권장)
|
||||
3. **Directory API 사용** (Google Workspace 필요)
|
||||
|
||||
```sql
|
||||
-- 시스템 설정 테이블에 그룹 목록 저장
|
||||
CREATE TABLE wr_google_group (
|
||||
group_id SERIAL PRIMARY KEY,
|
||||
group_email VARCHAR(200) NOT NULL UNIQUE,
|
||||
group_name VARCHAR(100),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO wr_google_group (group_email, group_name) VALUES
|
||||
('developers@company.com', '개발팀'),
|
||||
('team-leads@company.com', '팀장단'),
|
||||
('all@company.com', '전체');
|
||||
```
|
||||
|
||||
### 7.2 그룹 게시물 조회 로직
|
||||
|
||||
```typescript
|
||||
// Gmail API로 특정 그룹 메일 검색
|
||||
async function getGroupMessages(accessToken: string, groupEmail: string) {
|
||||
const response = await fetch(
|
||||
`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=list:${groupEmail}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.messages; // [{id, threadId}, ...]
|
||||
}
|
||||
|
||||
// 메시지 상세 조회
|
||||
async function getMessage(accessToken: string, messageId: string) {
|
||||
const response = await fetch(
|
||||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 그룹에 메일 발송 로직
|
||||
|
||||
```typescript
|
||||
async function sendToGroup(accessToken: string, to: string, subject: string, body: string) {
|
||||
const email = [
|
||||
`To: ${to}`,
|
||||
`Subject: =?UTF-8?B?${Buffer.from(subject).toString('base64')}?=`,
|
||||
'Content-Type: text/plain; charset=UTF-8',
|
||||
'',
|
||||
body
|
||||
].join('\r\n');
|
||||
|
||||
const encodedEmail = Buffer.from(email)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
const response = await fetch(
|
||||
'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ raw: encodedEmail })
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 작업 일정
|
||||
|
||||
### Phase 1: OAuth Scope 확장 + 토큰 저장 (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] Google Cloud Console OAuth scope 추가
|
||||
- [ ] wr_employee_info에 토큰 저장 컬럼 추가
|
||||
- [ ] OAuth 콜백에서 access/refresh 토큰 저장
|
||||
- [ ] 토큰 갱신 로직
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 그룹 게시물 조회 (3일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] wr_google_group 테이블 생성
|
||||
- [ ] 그룹 목록 API
|
||||
- [ ] 그룹 게시물 목록 API (Gmail API 연동)
|
||||
- [ ] 게시물 상세 API
|
||||
- [ ] 그룹 게시물 조회 페이지
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 주간보고 그룹 공유 (3일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] wr_report_group_share 테이블 생성
|
||||
- [ ] 그룹 공유 API (Gmail 발송)
|
||||
- [ ] 공유 이력 API
|
||||
- [ ] 주간보고 상세에 공유 UI 추가
|
||||
- [ ] 이메일 본문 템플릿
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 테스트 + 마무리 (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 전체 플로우 테스트
|
||||
- [ ] 토큰 만료 시 갱신 테스트
|
||||
- [ ] 오류 처리 (권한 없음, 그룹 미가입 등)
|
||||
- [ ] 관리자 - 그룹 목록 관리 페이지
|
||||
|
||||
---
|
||||
|
||||
## 작업 완료 결과
|
||||
|
||||
### Phase별 작업 시간
|
||||
|
||||
| Phase | 작업 내용 | 시작 | 완료 | 소요시간 |
|
||||
|:-----:|----------|:----:|:----:|:--------:|
|
||||
| 1 | OAuth Scope 확장 + 토큰 저장 | - | - | - |
|
||||
| 2 | 그룹 게시물 조회 | - | - | - |
|
||||
| 3 | 주간보고 그룹 공유 | - | - | - |
|
||||
| 4 | 테스트 + 마무리 | - | - | - |
|
||||
| | | | **총 소요시간** | **-** |
|
||||
|
||||
---
|
||||
|
||||
### 생성/수정된 파일
|
||||
|
||||
| 구분 | 파일 | 작업 |
|
||||
|------|------|:----:|
|
||||
| **DB** | wr_employee_info | 수정 (토큰 컬럼 추가) |
|
||||
| **DB** | wr_google_group | 신규 테이블 |
|
||||
| **DB** | wr_report_group_share | 신규 테이블 |
|
||||
| **API** | backend/api/google-group/my-groups.get.ts | 신규 |
|
||||
| **API** | backend/api/google-group/[groupEmail]/messages.get.ts | 신규 |
|
||||
| **API** | backend/api/google-group/message/[id].get.ts | 신규 |
|
||||
| **API** | backend/api/google-group/share.post.ts | 신규 |
|
||||
| **API** | backend/api/report/[id]/share-history.get.ts | 신규 |
|
||||
| **Frontend** | frontend/pages/google-group/index.vue | 신규 |
|
||||
| **Frontend** | frontend/pages/report/[id].vue | 수정 (공유 UI) |
|
||||
| **Utils** | backend/utils/gmail-api.ts | 신규 |
|
||||
| **Utils** | backend/utils/google-token.ts | 신규 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 기술 스택
|
||||
|
||||
- **Backend**: Nitro (H3) + PostgreSQL
|
||||
- **Frontend**: Nuxt3 + Vue3 + Bootstrap 5
|
||||
- **외부 API**: Gmail API v1
|
||||
- **인증**: OAuth 2.0 (4번 작업 확장)
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
### 10.1 Gmail API 제한
|
||||
- **일일 할당량**: 사용자당 250개 할당량 단위/일
|
||||
- **메일 발송**: 건당 100 할당량 단위
|
||||
- 대량 발송 시 제한 주의
|
||||
|
||||
### 10.2 민감한 Scope
|
||||
- Gmail readonly/send는 "민감한 범위"
|
||||
- Google Workspace 내부 앱은 관리자 승인으로 해결
|
||||
|
||||
### 10.3 토큰 보안
|
||||
- access_token, refresh_token 암호화 저장 권장
|
||||
- HTTPS 필수
|
||||
|
||||
---
|
||||
|
||||
## 11. 향후 확장 고려
|
||||
|
||||
1. **게시물 시스템 연동**: 그룹 게시물 → 회의록/공지사항으로 저장
|
||||
2. **자동 공유**: 주간보고 확정 시 자동으로 그룹 공유
|
||||
3. **그룹별 자동 선택**: 프로젝트 → 그룹 매핑으로 자동 선택
|
||||
4. **공유 알림**: 그룹 공유 시 시스템 알림
|
||||
723
claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md
Normal file
723
claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md
Normal file
@@ -0,0 +1,723 @@
|
||||
# SVN/Git 커밋 내역 연동 작업계획서
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 예상 기간: 2~3주
|
||||
> 우선순위: 7
|
||||
> 선행 작업: 2번 (사업-프로젝트 계층 구조)
|
||||
|
||||
---
|
||||
|
||||
## 1. 기능 개요
|
||||
|
||||
### 1.1 핵심 컨셉
|
||||
- 프로젝트별 **SVN/Git 저장소 주소 관리** (다중 가능)
|
||||
- 해당 주차 **커밋 이력 자동 수집**
|
||||
- 주간보고 작성 시 **커밋 코멘트 참고용 표시**
|
||||
- 작성자가 커밋 내용 보고 업무 내용 작성에 활용
|
||||
|
||||
### 1.2 전체 흐름
|
||||
|
||||
```
|
||||
[관리자/PM - 프로젝트 설정]
|
||||
프로젝트별 저장소 주소 등록
|
||||
↓
|
||||
[시스템 - 자동 수집]
|
||||
주기적으로 커밋 이력 수집 (Cron)
|
||||
↓
|
||||
[개발자 - 주간보고 작성]
|
||||
해당 주차 본인 커밋 내역 참고하며 작성
|
||||
```
|
||||
|
||||
### 1.3 결정 사항
|
||||
|
||||
| # | 항목 | 결정 |
|
||||
|:-:|------|:----:|
|
||||
| 1 | 저장소 타입 | SVN, Git 둘 다 지원 |
|
||||
| 2 | 저장소 개수 | 프로젝트당 다중 저장소 가능 |
|
||||
| 3 | 수집 방식 | **하이브리드**: 스케줄(1일1회) + 새로고침 버튼 |
|
||||
| 4 | VCS 아이디 관리 | **도메인(서버) 단위**로 사용자별 아이디 관리 |
|
||||
| 5 | 커밋 매칭 | VCS 서버 + 아이디 → 시스템 사용자 매칭 |
|
||||
| 6 | 용도 | 참고용 표시 (자동 입력 X) |
|
||||
| 7 | 조회 화면 | 프로젝트별 VCS 주소 기준 커밋 조회 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 모델
|
||||
|
||||
### 2.1 VCS 서버 테이블 (도메인 단위)
|
||||
|
||||
```sql
|
||||
-- VCS 서버 (도메인) 관리
|
||||
CREATE TABLE wr_vcs_server (
|
||||
server_id SERIAL PRIMARY KEY,
|
||||
server_type VARCHAR(10) NOT NULL, -- SVN, GIT
|
||||
server_url VARCHAR(300) NOT NULL, -- github.com, svn://192.168.1.100
|
||||
server_name VARCHAR(100), -- 표시용 이름 (예: "사내 SVN", "GitHub")
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(server_url)
|
||||
);
|
||||
|
||||
-- 예시 데이터
|
||||
INSERT INTO wr_vcs_server (server_type, server_url, server_name) VALUES
|
||||
('GIT', 'github.com', 'GitHub'),
|
||||
('GIT', 'gitlab.company.com', '사내 GitLab'),
|
||||
('SVN', 'svn://192.168.1.100', '사내 SVN');
|
||||
```
|
||||
|
||||
### 2.2 사용자별 VCS 계정 테이블
|
||||
|
||||
```sql
|
||||
-- 사용자별 VCS 서버 계정 (도메인별로 아이디 관리)
|
||||
CREATE TABLE wr_employee_vcs_account (
|
||||
account_id SERIAL PRIMARY KEY,
|
||||
employee_id INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
|
||||
server_id INTEGER NOT NULL REFERENCES wr_vcs_server(server_id),
|
||||
vcs_username VARCHAR(100) NOT NULL, -- SVN/Git 사용자명
|
||||
vcs_email VARCHAR(100), -- Git 커밋 이메일 (Git만 해당)
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(employee_id, server_id) -- 사용자당 서버별 1개
|
||||
);
|
||||
|
||||
CREATE INDEX idx_vcs_account_employee ON wr_employee_vcs_account(employee_id);
|
||||
CREATE INDEX idx_vcs_account_server ON wr_employee_vcs_account(server_id);
|
||||
```
|
||||
|
||||
### 2.3 저장소 정보 테이블
|
||||
|
||||
```sql
|
||||
-- 프로젝트별 저장소 (VCS 서버 하위)
|
||||
CREATE TABLE wr_repository (
|
||||
repo_id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES wr_project_info(project_id),
|
||||
server_id INTEGER NOT NULL REFERENCES wr_vcs_server(server_id),
|
||||
repo_name VARCHAR(100), -- 표시용 이름
|
||||
repo_path VARCHAR(500) NOT NULL, -- 저장소 경로 (예: /company/frontend.git, /pims/trunk)
|
||||
branch_name VARCHAR(100), -- Git: 브랜치명 (기본: main)
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_sync_at TIMESTAMP,
|
||||
last_sync_status VARCHAR(20), -- SUCCESS, FAILED
|
||||
last_sync_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
created_by INTEGER REFERENCES wr_employee_info(employee_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_repository_project ON wr_repository(project_id);
|
||||
CREATE INDEX idx_repository_server ON wr_repository(server_id);
|
||||
|
||||
-- 전체 URL = wr_vcs_server.server_url + wr_repository.repo_path
|
||||
-- 예: github.com + /company/frontend.git = github.com/company/frontend.git
|
||||
```
|
||||
|
||||
### 2.4 커밋 이력 테이블
|
||||
|
||||
```sql
|
||||
CREATE TABLE wr_commit_log (
|
||||
commit_id SERIAL PRIMARY KEY,
|
||||
repo_id INTEGER NOT NULL REFERENCES wr_repository(repo_id),
|
||||
commit_hash VARCHAR(100) NOT NULL, -- Git: SHA, SVN: revision
|
||||
commit_message TEXT,
|
||||
commit_author VARCHAR(200), -- 커밋 작성자 (원본)
|
||||
commit_email VARCHAR(200), -- 커밋 이메일 (Git)
|
||||
commit_date TIMESTAMP NOT NULL,
|
||||
employee_id INTEGER REFERENCES wr_employee_info(employee_id), -- 매칭된 사용자
|
||||
files_changed INTEGER,
|
||||
insertions INTEGER,
|
||||
deletions INTEGER,
|
||||
synced_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(repo_id, commit_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_commit_repo ON wr_commit_log(repo_id);
|
||||
CREATE INDEX idx_commit_date ON wr_commit_log(commit_date);
|
||||
CREATE INDEX idx_commit_employee ON wr_commit_log(employee_id);
|
||||
```
|
||||
|
||||
### 2.5 데이터 관계도
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ wr_vcs_server │ (도메인 단위: github.com, svn://192.168.1.100)
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────┴────┐
|
||||
↓ ↓
|
||||
┌─────────┐ ┌──────────────────────┐
|
||||
│wr_repos │ │wr_employee_vcs_account│
|
||||
│itory │ │ (사용자별 도메인 아이디)│
|
||||
└────┬────┘ └──────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌──────────────┐
|
||||
│wr_commit_log │ (커밋 이력)
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API 설계
|
||||
|
||||
### 3.1 VCS 서버 관리 API (관리자)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/vcs-server | VCS 서버 목록 |
|
||||
| POST | /api/vcs-server | VCS 서버 추가 |
|
||||
| PUT | /api/vcs-server/[id] | VCS 서버 수정 |
|
||||
| DELETE | /api/vcs-server/[id] | VCS 서버 삭제 |
|
||||
|
||||
### 3.2 사용자 VCS 계정 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/my/vcs-accounts | 내 VCS 계정 목록 |
|
||||
| POST | /api/my/vcs-account | VCS 계정 등록/수정 |
|
||||
| DELETE | /api/my/vcs-account/[serverId] | VCS 계정 삭제 |
|
||||
|
||||
### 3.3 저장소 관리 API
|
||||
|
||||
| Method | Endpoint | 설명 | 권한 |
|
||||
|--------|----------|------|:----:|
|
||||
| GET | /api/project/[id]/repositories | 프로젝트 저장소 목록 | 멤버 |
|
||||
| POST | /api/project/[id]/repository | 저장소 추가 | PM+ |
|
||||
| PUT | /api/repository/[id] | 저장소 수정 | PM+ |
|
||||
| DELETE | /api/repository/[id] | 저장소 삭제 | PM+ |
|
||||
| POST | /api/repository/[id]/sync | 수동 동기화 | PM+ |
|
||||
|
||||
### 3.4 커밋 조회 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/project/[id]/commits | 프로젝트 커밋 목록 (필터: 기간, 작성자) |
|
||||
| GET | /api/commits/my-weekly | 내 이번 주 커밋 (주간보고용) |
|
||||
| POST | /api/project/[id]/commits/refresh | 최신 커밋 새로고침 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 설계
|
||||
|
||||
### 4.1 관리자 - VCS 서버 관리 (/admin/vcs-server)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ⚙️ VCS 서버 관리 [+ 서버 추가] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🟢 [Git] GitHub │ │
|
||||
│ │ github.com │ │
|
||||
│ │ [수정] [삭제] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🟢 [Git] 사내 GitLab │ │
|
||||
│ │ gitlab.company.com │ │
|
||||
│ │ [수정] [삭제] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔶 [SVN] 사내 SVN │ │
|
||||
│ │ svn://192.168.1.100 │ │
|
||||
│ │ [수정] [삭제] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 마이페이지 - VCS 계정 설정
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 마이페이지 > VCS 계정 설정 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ※ 커밋 내역 조회 시 아래 정보로 본인 커밋을 찾습니다. │
|
||||
│ │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 🟢 GitHub (github.com) │
|
||||
│ 사용자명: [hyosung_______________] │
|
||||
│ 이메일: [hyosung@gmail.com_____] │
|
||||
│ │
|
||||
│ 🟢 사내 GitLab (gitlab.company.com) │
|
||||
│ 사용자명: [cho.hyosung___________] │
|
||||
│ 이메일: [hyosung@company.com___] │
|
||||
│ │
|
||||
│ 🔶 사내 SVN (svn://192.168.1.100) │
|
||||
│ 사용자명: [hyosung_______________] │
|
||||
│ │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ [저장] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 프로젝트 상세 - 저장소 관리
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 프로젝트: PIMS 시스템 [수정] [삭제] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📋 기본 정보 │
|
||||
│ ... │
|
||||
│ │
|
||||
│ 📁 저장소 관리 [+ 저장소 추가]│
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔶 [SVN] 메인 저장소 │ │
|
||||
│ │ 사내 SVN > /pims/trunk │ │
|
||||
│ │ 마지막 동기화: 2026-01-10 06:00 ✅ │ │
|
||||
│ │ [동기화] [수정] [삭제] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🟢 [Git] Frontend │ │
|
||||
│ │ GitHub > /company/pims-frontend.git (main) │ │
|
||||
│ │ 마지막 동기화: 2026-01-10 06:00 ✅ │ │
|
||||
│ │ [동기화] [수정] [삭제] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🟢 [Git] Backend │ │
|
||||
│ │ GitHub > /company/pims-backend.git (main) │ │
|
||||
│ │ 마지막 동기화: 2026-01-10 06:00 ✅ │ │
|
||||
│ │ [동기화] [수정] [삭제] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [📊 커밋 내역 보기] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.4 프로젝트 커밋 조회 (/project/[id]/commits)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 📊 PIMS 시스템 - 커밋 내역 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 기간: [2026-01-06] ~ [2026-01-10] │
|
||||
│ 저장소: [전체 ▼] 작성자: [전체 ▼] │
|
||||
│ [검색] [🔄 새로고침] │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────────────│
|
||||
│ 날짜 저장소 작성자 메시지 │
|
||||
│ ───────────────────────────────────────────────────────────────│
|
||||
│ 01-10 14:30 [Git] Frontend 조효성 로그인 버그 수정 │
|
||||
│ 01-10 11:00 [Git] Backend 조효성 OAuth 콜백 처리 │
|
||||
│ 01-10 09:00 [Git] Frontend 서혜원 대시보드 차트 수정 │
|
||||
│ 01-09 16:00 [SVN] 메인 r1234 조효성 사용자 관리 수정 │
|
||||
│ 01-09 14:00 [Git] Backend 홍길동 API 엔드포인트 추가│
|
||||
│ 01-08 10:30 [SVN] 메인 r1233 조효성 DB 스키마 변경 │
|
||||
│ ... │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────────────│
|
||||
│ 이번 주 커밋: 총 25건 | +1,234줄 / -456줄 │
|
||||
│ │
|
||||
│ [1] [2] [3] ... [5] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.5 주간보고 작성 - 커밋 내역 참고
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 📝 주간보고 작성 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 연도/주차: [2026] 년 [2] 주차 │
|
||||
│ 프로젝트: [PIMS 시스템 ▼] │
|
||||
│ │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 📋 이번 주 내 커밋 (참고용) [🔄 새로고침]│
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 01-10 금 14:30 [Git-Frontend] 로그인 기능 버그 수정 │ │
|
||||
│ │ │ │
|
||||
│ │ 01-10 금 11:00 [Git-Backend] OAuth 콜백 처리 추가 │ │
|
||||
│ │ │ │
|
||||
│ │ 01-09 목 16:00 [SVN] r1234 사용자 관리 페이지 수정 │ │
|
||||
│ │ │ │
|
||||
│ │ 01-08 수 10:30 [SVN] r1233 DB 스키마 변경 │ │
|
||||
│ │ │ │
|
||||
│ │ ─────────────────────────────────────── │ │
|
||||
│ │ 총 4건 | +156줄 / -42줄 │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ※ VCS 계정 미등록 시: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ VCS 계정이 등록되지 않았습니다. │ │
|
||||
│ │ [마이페이지 > VCS 계정 설정]에서 등록해주세요. │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 금주 업무: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ (위 커밋 내역 참고하여 작성) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 커밋 수집 로직
|
||||
|
||||
### 5.1 수집 방식 (하이브리드)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 커밋 수집 방식 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [자동 스케줄] │
|
||||
│ └─ 매일 새벽 6시 Cron으로 전체 저장소 동기화 │
|
||||
│ └─ 수집된 커밋 → wr_commit_log 테이블에 저장 │
|
||||
│ │
|
||||
│ [주간보고 작성 시] │
|
||||
│ └─ DB에서 바로 조회 (빠름!) │
|
||||
│ │
|
||||
│ [새로고침 버튼] │
|
||||
│ └─ 해당 프로젝트 저장소만 최신 동기화 │
|
||||
│ └─ 방금 커밋한 내용도 확인 가능 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 작성자 매칭 로직
|
||||
|
||||
```typescript
|
||||
// 커밋 작성자 → 시스템 사용자 매칭
|
||||
async function matchCommitAuthor(
|
||||
serverId: number,
|
||||
commitAuthor: string, // 커밋 작성자명
|
||||
commitEmail: string // 커밋 이메일 (Git)
|
||||
): Promise<number | null> {
|
||||
|
||||
// VCS 계정 테이블에서 매칭
|
||||
// 같은 서버에서 username 또는 email이 일치하는 사용자 찾기
|
||||
const matched = await db.query(`
|
||||
SELECT e.employee_id
|
||||
FROM wr_employee_vcs_account a
|
||||
JOIN wr_employee_info e ON a.employee_id = e.employee_id
|
||||
WHERE a.server_id = $1
|
||||
AND (a.vcs_username = $2 OR a.vcs_email = $3)
|
||||
`, [serverId, commitAuthor, commitEmail]);
|
||||
|
||||
if (matched.rows.length > 0) {
|
||||
return matched.rows[0].employee_id;
|
||||
}
|
||||
|
||||
return null; // 매칭 실패
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Git 커밋 수집
|
||||
|
||||
```typescript
|
||||
// simple-git 라이브러리 사용
|
||||
import simpleGit from 'simple-git';
|
||||
|
||||
async function syncGitCommits(repo: Repository) {
|
||||
const git = simpleGit();
|
||||
|
||||
// 임시 디렉토리에 clone 또는 pull
|
||||
const repoPath = `/tmp/repos/${repo.repo_id}`;
|
||||
|
||||
if (existsSync(repoPath)) {
|
||||
await git.cwd(repoPath).pull();
|
||||
} else {
|
||||
await git.clone(repo.repo_url, repoPath, ['--branch', repo.branch_name]);
|
||||
}
|
||||
|
||||
// 최근 커밋 조회 (마지막 동기화 이후)
|
||||
const logs = await git.cwd(repoPath).log({
|
||||
'--since': repo.last_sync_at || '1 week ago',
|
||||
'--format': '%H|%an|%ae|%aI|%s'
|
||||
});
|
||||
|
||||
// DB에 저장
|
||||
for (const commit of logs.all) {
|
||||
await saveCommit({
|
||||
repo_id: repo.repo_id,
|
||||
commit_hash: commit.hash,
|
||||
commit_message: commit.message,
|
||||
commit_author: commit.author_email,
|
||||
commit_date: commit.date,
|
||||
// ...
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 SVN 커밋 수집
|
||||
|
||||
```typescript
|
||||
// svn 명령어 실행
|
||||
import { exec } from 'child_process';
|
||||
|
||||
async function syncSvnCommits(repo: Repository) {
|
||||
const since = repo.last_sync_at
|
||||
? `{${repo.last_sync_at.toISOString()}}`
|
||||
: 'HEAD-100';
|
||||
|
||||
const command = `svn log ${repo.repo_url} -r ${since}:HEAD --xml`;
|
||||
|
||||
// 인증 정보 추가
|
||||
if (repo.auth_username) {
|
||||
command += ` --username ${repo.auth_username} --password ${repo.auth_credential}`;
|
||||
}
|
||||
|
||||
const result = await execPromise(command);
|
||||
const logs = parseXml(result); // XML 파싱
|
||||
|
||||
for (const entry of logs) {
|
||||
await saveCommit({
|
||||
repo_id: repo.repo_id,
|
||||
commit_hash: entry.revision,
|
||||
commit_message: entry.msg,
|
||||
commit_author: entry.author,
|
||||
commit_date: entry.date,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 서버 인증 정보 관리
|
||||
|
||||
VCS 서버 접근 시 필요한 인증 정보 (서버 레벨)
|
||||
|
||||
```sql
|
||||
-- VCS 서버에 인증 정보 추가 (필요 시)
|
||||
ALTER TABLE wr_vcs_server
|
||||
ADD COLUMN auth_type VARCHAR(20) DEFAULT 'NONE', -- NONE, BASIC, TOKEN, SSH_KEY
|
||||
ADD COLUMN auth_username VARCHAR(100),
|
||||
ADD COLUMN auth_credential TEXT; -- 암호화 저장
|
||||
```
|
||||
|
||||
※ 공개 저장소는 인증 불필요
|
||||
※ 사내 SVN/GitLab은 서버 레벨 인증 또는 사용자별 인증 선택
|
||||
|
||||
---
|
||||
|
||||
## 6. 환경 설정
|
||||
|
||||
### 6.1 필요 패키지
|
||||
|
||||
```bash
|
||||
# Git 연동
|
||||
npm install simple-git
|
||||
|
||||
# SVN 연동 (시스템에 svn 명령어 필요)
|
||||
# Ubuntu: apt install subversion
|
||||
# macOS: brew install svn
|
||||
```
|
||||
|
||||
### 6.2 환경 변수
|
||||
|
||||
```env
|
||||
# 커밋 수집 설정
|
||||
COMMIT_SYNC_ENABLED=true
|
||||
COMMIT_SYNC_CRON=0 6 * * * # 매일 새벽 6시
|
||||
COMMIT_SYNC_TEMP_DIR=/tmp/repos # 임시 저장소 디렉토리
|
||||
|
||||
# 보안 (인증 정보 암호화 키)
|
||||
REPO_CREDENTIAL_SECRET=your-secret-key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 보안 고려사항
|
||||
|
||||
### 7.1 인증 정보 보안
|
||||
|
||||
| 항목 | 처리 방식 |
|
||||
|------|----------|
|
||||
| 비밀번호/토큰 | AES-256 암호화 저장 |
|
||||
| SSH 키 | 파일로 저장, 권한 제한 (600) |
|
||||
| 환경 변수 | 민감 정보 노출 방지 |
|
||||
|
||||
### 7.2 접근 권한
|
||||
|
||||
| 역할 | 권한 |
|
||||
|------|------|
|
||||
| 관리자 | 모든 저장소 관리, 전체 커밋 조회 |
|
||||
| PM | 담당 프로젝트 저장소 관리 |
|
||||
| 멤버 | 본인 커밋만 조회 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 작업 일정
|
||||
|
||||
### Phase 1: DB + VCS 서버/계정 관리 (3일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] wr_vcs_server 테이블 생성
|
||||
- [ ] wr_employee_vcs_account 테이블 생성
|
||||
- [ ] wr_repository 테이블 생성
|
||||
- [ ] wr_commit_log 테이블 생성
|
||||
- [ ] VCS 서버 관리 API + UI (관리자)
|
||||
- [ ] 마이페이지 VCS 계정 설정 UI
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 저장소 관리 (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 저장소 CRUD API
|
||||
- [ ] 프로젝트 상세에 저장소 관리 UI
|
||||
- [ ] 저장소 추가/수정 모달
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Git 커밋 수집 (3일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] simple-git 연동
|
||||
- [ ] Git 커밋 수집 로직
|
||||
- [ ] 작성자 매칭 (VCS 계정 기반)
|
||||
- [ ] 수동 동기화 API
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: SVN 커밋 수집 (3일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] SVN 명령어 연동
|
||||
- [ ] SVN 커밋 수집 로직
|
||||
- [ ] XML 파싱
|
||||
- [ ] 작성자 매칭
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 커밋 조회 화면 (3일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 프로젝트별 커밋 조회 API
|
||||
- [ ] 프로젝트 커밋 조회 페이지 (/project/[id]/commits)
|
||||
- [ ] 주간보고 작성 시 커밋 참고 UI
|
||||
- [ ] 새로고침 버튼 (최신 동기화)
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: 자동화 + 테스트 (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] Cron Job 설정 (매일 새벽 자동 동기화)
|
||||
- [ ] 인증 정보 암호화
|
||||
- [ ] 전체 플로우 테스트
|
||||
- [ ] 오류 처리
|
||||
|
||||
---
|
||||
|
||||
## 작업 완료 결과
|
||||
|
||||
### Phase별 작업 시간
|
||||
|
||||
| Phase | 작업 내용 | 시작 | 완료 | 소요시간 |
|
||||
|:-----:|----------|:----:|:----:|:--------:|
|
||||
| 1 | DB + VCS 서버/계정 관리 | - | - | - |
|
||||
| 2 | 저장소 관리 | - | - | - |
|
||||
| 3 | Git 커밋 수집 | - | - | - |
|
||||
| 4 | SVN 커밋 수집 | - | - | - |
|
||||
| 5 | 커밋 조회 화면 | - | - | - |
|
||||
| 6 | 자동화 + 테스트 | - | - | - |
|
||||
| | | | **총 소요시간** | **-** |
|
||||
|
||||
---
|
||||
|
||||
### 생성/수정된 파일
|
||||
|
||||
| 구분 | 파일 | 작업 |
|
||||
|------|------|:----:|
|
||||
| **DB** | wr_vcs_server | 신규 테이블 |
|
||||
| **DB** | wr_employee_vcs_account | 신규 테이블 |
|
||||
| **DB** | wr_repository | 신규 테이블 |
|
||||
| **DB** | wr_commit_log | 신규 테이블 |
|
||||
| **API** | backend/api/vcs-server/* | 신규 (CRUD) |
|
||||
| **API** | backend/api/my/vcs-accounts.get.ts | 신규 |
|
||||
| **API** | backend/api/my/vcs-account.post.ts | 신규 |
|
||||
| **API** | backend/api/project/[id]/repositories.get.ts | 신규 |
|
||||
| **API** | backend/api/project/[id]/repository.post.ts | 신규 |
|
||||
| **API** | backend/api/repository/[id].put.ts | 신규 |
|
||||
| **API** | backend/api/repository/[id]/sync.post.ts | 신규 |
|
||||
| **API** | backend/api/project/[id]/commits.get.ts | 신규 |
|
||||
| **API** | backend/api/project/[id]/commits/refresh.post.ts | 신규 |
|
||||
| **API** | backend/api/commits/my-weekly.get.ts | 신규 |
|
||||
| **Frontend** | frontend/pages/admin/vcs-server.vue | 신규 |
|
||||
| **Frontend** | frontend/pages/mypage.vue | 수정 (VCS 계정) |
|
||||
| **Frontend** | frontend/pages/project/[id].vue | 수정 (저장소 관리) |
|
||||
| **Frontend** | frontend/pages/project/[id]/commits.vue | 신규 |
|
||||
| **Frontend** | frontend/pages/report/write.vue | 수정 (커밋 참고) |
|
||||
| **Backend** | backend/services/git-sync.ts | 신규 |
|
||||
| **Backend** | backend/services/svn-sync.ts | 신규 |
|
||||
| **Backend** | backend/jobs/commit-sync.ts | 신규 (Cron) |
|
||||
| **Utils** | backend/utils/crypto.ts | 신규 (암호화) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 기술 스택
|
||||
|
||||
- **Backend**: Nitro (H3) + PostgreSQL
|
||||
- **Frontend**: Nuxt3 + Vue3 + Bootstrap 5
|
||||
- **Git 연동**: simple-git (npm 패키지)
|
||||
- **SVN 연동**: svn CLI (시스템 명령어)
|
||||
- **스케줄링**: node-cron 또는 시스템 crontab
|
||||
- **암호화**: crypto (Node.js 내장)
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
### 10.1 서버 환경
|
||||
- SVN 사용 시 서버에 `svn` 명령어 설치 필요
|
||||
- Git 사용 시 서버에 `git` 명령어 설치 필요
|
||||
- 임시 디렉토리 용량 관리 필요
|
||||
|
||||
### 10.2 네트워크
|
||||
- 내부 SVN 서버 접근 가능해야 함
|
||||
- GitHub/GitLab 등 외부 서비스 접근 가능해야 함
|
||||
- 방화벽 설정 확인
|
||||
|
||||
### 10.3 성능
|
||||
- 대용량 저장소는 shallow clone 고려
|
||||
- 커밋 수집 시 서버 부하 주의 (새벽 실행 권장)
|
||||
|
||||
---
|
||||
|
||||
## 11. 향후 확장 고려
|
||||
|
||||
1. **AI 연동**: 커밋 메시지 → 주간보고 자동 생성 초안
|
||||
2. **코드 통계**: 라인 수, 파일 수 통계 대시보드
|
||||
3. **커밋-업무 연결**: 커밋을 특정 업무 항목에 연결
|
||||
4. **GitLab/GitHub API**: CLI 대신 API 직접 연동
|
||||
5. **WebHook**: Push 이벤트 시 실시간 수집
|
||||
204
frontend/business-report/[id].vue
Normal file
204
frontend/business-report/[id].vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<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="/business-report" class="text-decoration-none text-muted me-2"><i class="bi bi-arrow-left"></i></NuxtLink>
|
||||
<i class="bi bi-file-earmark-text me-2"></i>{{ report?.businessName }} - {{ report?.reportYear }}-W{{ String(report?.reportWeek || 0).padStart(2, '0') }}
|
||||
</h4>
|
||||
<div v-if="report">
|
||||
<span :class="report.status === 'confirmed' ? 'badge bg-success me-3' : 'badge bg-warning me-3'">
|
||||
{{ report.status === 'confirmed' ? '확정' : '작성중' }}
|
||||
</span>
|
||||
<button class="btn btn-success" @click="confirmReport" v-if="report.status !== 'confirmed'" :disabled="isConfirming">
|
||||
<i class="bi bi-check-lg me-1"></i>확정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="report" class="row">
|
||||
<div class="col-md-8">
|
||||
<!-- 요약 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>취합 요약</strong>
|
||||
<button class="btn btn-sm btn-outline-primary" @click="toggleEdit" v-if="report.status !== 'confirmed'">
|
||||
{{ isEditing ? '취소' : '수정' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="!isEditing">
|
||||
<div class="mb-3" v-if="report.manualSummary">
|
||||
<label class="text-muted small">최종 요약 (수정됨)</label>
|
||||
<div style="white-space: pre-wrap;">{{ report.manualSummary }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted small">AI 생성 요약</label>
|
||||
<div style="white-space: pre-wrap;" :class="{ 'text-muted': report.manualSummary }">{{ report.aiSummary }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<label class="form-label">요약 수정</label>
|
||||
<textarea class="form-control" v-model="editSummary" rows="8" placeholder="AI 요약을 수정하세요..."></textarea>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-primary btn-sm" @click="saveSummary" :disabled="isSaving">
|
||||
<span v-if="isSaving"><span class="spinner-border spinner-border-sm me-1"></span></span>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실적 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>포함된 실적 목록</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 120px">프로젝트</th>
|
||||
<th style="width: 80px">담당자</th>
|
||||
<th>실적 내용</th>
|
||||
<th style="width: 60px" class="text-center">시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in tasks" :key="t.taskId">
|
||||
<td>{{ t.projectName }}</td>
|
||||
<td>{{ t.employeeName }}</td>
|
||||
<td>{{ t.taskDescription }}</td>
|
||||
<td class="text-center">{{ t.taskHours || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="tasks.length === 0">
|
||||
<td colspan="4" class="text-center py-3 text-muted">실적이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<!-- 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>보고서 정보</strong></div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">사업</span>
|
||||
<NuxtLink :to="`/business/${report.businessId}`">{{ report.businessName }}</NuxtLink>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">주차</span>
|
||||
<span>{{ report.reportYear }}-W{{ String(report.reportWeek).padStart(2, '0') }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">기간</span>
|
||||
<span>{{ formatDate(report.weekStartDate) }} ~ {{ formatDate(report.weekEndDate) }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">실적 건수</span>
|
||||
<span>{{ tasks.length }}건</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">생성자</span>
|
||||
<span>{{ report.createdByName || '-' }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">생성일</span>
|
||||
<span>{{ formatDateTime(report.createdAt) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const report = ref<any>(null)
|
||||
const tasks = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isConfirming = ref(false)
|
||||
const editSummary = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
await loadReport()
|
||||
})
|
||||
|
||||
async function loadReport() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ report: any; tasks: any[] }>(`/api/business-report/${route.params.id}/detail`)
|
||||
report.value = res.report
|
||||
tasks.value = res.tasks || []
|
||||
} catch (e: any) {
|
||||
alert('보고서를 불러올 수 없습니다.')
|
||||
router.push('/business-report')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (!isEditing.value) {
|
||||
editSummary.value = report.value.manualSummary || report.value.aiSummary || ''
|
||||
}
|
||||
isEditing.value = !isEditing.value
|
||||
}
|
||||
|
||||
async function saveSummary() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch(`/api/business-report/${route.params.id}/update`, {
|
||||
method: 'PUT',
|
||||
body: { manualSummary: editSummary.value }
|
||||
})
|
||||
isEditing.value = false
|
||||
await loadReport()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReport() {
|
||||
if (!confirm('보고서를 확정하시겠습니까? 확정 후에는 수정할 수 없습니다.')) return
|
||||
isConfirming.value = true
|
||||
try {
|
||||
await $fetch(`/api/business-report/${route.params.id}/confirm`, { method: 'PUT' })
|
||||
await loadReport()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '확정에 실패했습니다.')
|
||||
} finally {
|
||||
isConfirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
if (!d) return '-'
|
||||
return d.split('T')[0]
|
||||
}
|
||||
|
||||
function formatDateTime(d: string) {
|
||||
if (!d) return '-'
|
||||
const dt = new Date(d)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')} ${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
272
frontend/business-report/index.vue
Normal file
272
frontend/business-report/index.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<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-file-earmark-text me-2"></i>사업 주간보고 취합</h4>
|
||||
</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-3">
|
||||
<select class="form-select form-select-sm" v-model="filter.businessId" @change="loadReports">
|
||||
<option value="">전체</option>
|
||||
<option v-for="b in businesses" :key="b.businessId" :value="b.businessId">{{ b.businessName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">년도</label></div>
|
||||
<div class="col-1">
|
||||
<select class="form-select form-select-sm" v-model="filter.year" @change="loadReports">
|
||||
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-primary btn-sm" @click="showGenerateModal = true" :disabled="!filter.businessId">
|
||||
<i class="bi bi-plus-lg me-1"></i>취합 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header">취합 목록</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th>사업명</th>
|
||||
<th style="width: 100px" class="text-center">주차</th>
|
||||
<th style="width: 180px">기간</th>
|
||||
<th>AI 요약 (미리보기)</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 me-2"></span>로딩 중...</td>
|
||||
</tr>
|
||||
<tr v-else-if="reports.length === 0">
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">취합된 보고서가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(r, idx) in reports" :key="r.businessReportId">
|
||||
<td class="text-center">{{ idx + 1 }}</td>
|
||||
<td>
|
||||
<NuxtLink :to="`/business-report/${r.businessReportId}`" class="text-decoration-none">
|
||||
{{ r.businessName }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td class="text-center">{{ r.reportYear }}-W{{ String(r.reportWeek).padStart(2, '0') }}</td>
|
||||
<td>{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}</td>
|
||||
<td><small class="text-muted">{{ truncate(r.manualSummary || r.aiSummary, 50) }}</small></td>
|
||||
<td class="text-center">
|
||||
<span :class="r.status === 'confirmed' ? 'badge bg-success' : 'badge bg-warning'">
|
||||
{{ r.status === 'confirmed' ? '확정' : '작성중' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(r.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 취합 생성 모달 -->
|
||||
<div class="modal fade" :class="{ show: showGenerateModal }" :style="{ display: showGenerateModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">주간보고 취합 생성</h5>
|
||||
<button type="button" class="btn-close" @click="showGenerateModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">사업 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" v-model="generateForm.businessId" disabled>
|
||||
<option v-for="b in businesses" :key="b.businessId" :value="b.businessId">{{ b.businessName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">주차 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" v-model="generateForm.week">
|
||||
<option v-for="w in weeks" :key="w.week" :value="w">
|
||||
{{ w.year }}-W{{ String(w.week).padStart(2, '0') }} ({{ w.startDate }} ~ {{ w.endDate }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
선택한 주차에 해당하는 프로젝트 실적을 AI가 요약합니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showGenerateModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="generateReport" :disabled="isGenerating">
|
||||
<span v-if="isGenerating"><span class="spinner-border spinner-border-sm me-1"></span>생성 중...</span>
|
||||
<span v-else><i class="bi bi-magic me-1"></i>AI 취합 생성</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showGenerateModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Business { businessId: number; businessName: string }
|
||||
interface Report {
|
||||
businessReportId: number
|
||||
businessId: number
|
||||
businessName: string
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
aiSummary: string
|
||||
manualSummary: string
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const businesses = ref<Business[]>([])
|
||||
const reports = ref<Report[]>([])
|
||||
const isLoading = ref(false)
|
||||
const showGenerateModal = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = [currentYear, currentYear - 1]
|
||||
|
||||
const filter = ref({
|
||||
businessId: '',
|
||||
year: currentYear
|
||||
})
|
||||
|
||||
const weeks = computed(() => {
|
||||
const result = []
|
||||
const now = new Date()
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const d = new Date(now.getTime() - i * 7 * 24 * 60 * 60 * 1000)
|
||||
const { year, week, startDate, endDate } = getWeekInfo(d)
|
||||
result.push({ year, week, startDate, endDate })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const generateForm = ref({
|
||||
businessId: '',
|
||||
week: null as any
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
await loadBusinesses()
|
||||
await loadReports()
|
||||
})
|
||||
|
||||
watch(() => filter.value.businessId, (v) => {
|
||||
generateForm.value.businessId = v
|
||||
if (weeks.value.length > 0) {
|
||||
generateForm.value.week = weeks.value[0]
|
||||
}
|
||||
})
|
||||
|
||||
async function loadBusinesses() {
|
||||
try {
|
||||
const res = await $fetch<{ businesses: Business[] }>('/api/business/list')
|
||||
businesses.value = res.businesses || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ reports: Report[] }>('/api/business-report/list', {
|
||||
query: {
|
||||
businessId: filter.value.businessId || undefined,
|
||||
year: filter.value.year
|
||||
}
|
||||
})
|
||||
reports.value = res.reports || []
|
||||
} catch (e) { console.error(e) }
|
||||
finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
if (!generateForm.value.businessId || !generateForm.value.week) {
|
||||
alert('사업과 주차를 선택하세요.')
|
||||
return
|
||||
}
|
||||
isGenerating.value = true
|
||||
try {
|
||||
const w = generateForm.value.week
|
||||
const res = await $fetch<{ report: any }>('/api/business-report/generate', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
businessId: Number(generateForm.value.businessId),
|
||||
reportYear: w.year,
|
||||
reportWeek: w.week,
|
||||
weekStartDate: w.startDate,
|
||||
weekEndDate: w.endDate
|
||||
}
|
||||
})
|
||||
showGenerateModal.value = false
|
||||
await loadReports()
|
||||
router.push(`/business-report/${res.report.businessReportId}`)
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '취합 생성에 실패했습니다.')
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getWeekInfo(date: Date) {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7)
|
||||
const week1 = new Date(d.getFullYear(), 0, 4)
|
||||
const week = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
|
||||
|
||||
const monday = new Date(date)
|
||||
monday.setDate(monday.getDate() - (monday.getDay() + 6) % 7)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(sunday.getDate() + 6)
|
||||
|
||||
return {
|
||||
year: d.getFullYear(),
|
||||
week,
|
||||
startDate: monday.toISOString().split('T')[0],
|
||||
endDate: sunday.toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
if (!d) return '-'
|
||||
return d.split('T')[0]
|
||||
}
|
||||
|
||||
function truncate(s: string, len: number) {
|
||||
if (!s) return '-'
|
||||
return s.length > len ? s.substring(0, len) + '...' : s
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show { background: rgba(0, 0, 0, 0.5); }
|
||||
</style>
|
||||
335
frontend/business/[id].vue
Normal file
335
frontend/business/[id].vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4>
|
||||
<i class="bi bi-briefcase me-2"></i>
|
||||
{{ business?.businessName }}
|
||||
</h4>
|
||||
<p class="text-muted mb-0">
|
||||
<span :class="getStatusBadgeClass(business?.businessStatus || '')">
|
||||
{{ getStatusText(business?.businessStatus || '') }}
|
||||
</span>
|
||||
<span v-if="business?.businessCode" class="ms-2">
|
||||
<code>{{ business.businessCode }}</code>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NuxtLink to="/business" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-arrow-left me-1"></i> 목록
|
||||
</NuxtLink>
|
||||
<button class="btn btn-primary me-2" @click="openEditModal">
|
||||
<i class="bi bi-pencil me-1"></i> 수정
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" @click="deleteBusiness">
|
||||
<i class="bi bi-trash me-1"></i> 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="business" class="row">
|
||||
<!-- 왼쪽: 기본 정보 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>기본 정보</strong></div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">사업코드</span>
|
||||
<span>{{ business.businessCode || '-' }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">발주처</span>
|
||||
<span>{{ business.clientName || '-' }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">계약 시작일</span>
|
||||
<span>{{ formatDate(business.contractStartDate) || '-' }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">계약 종료일</span>
|
||||
<span>{{ formatDate(business.contractEndDate) || '-' }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">등록자</span>
|
||||
<span>{{ business.createdByName || '-' }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">등록일</span>
|
||||
<span>{{ formatDateTime(business.createdAt) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card-body" v-if="business.description">
|
||||
<label class="form-label text-muted">설명</label>
|
||||
<p class="mb-0">{{ business.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 소속 프로젝트 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>소속 프로젝트 ({{ projects.length }}개)</strong>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>프로젝트명</th>
|
||||
<th style="width: 80px">유형</th>
|
||||
<th style="width: 150px">기간</th>
|
||||
<th style="width: 100px">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in projects" :key="p.projectId">
|
||||
<td>
|
||||
<NuxtLink :to="`/project/${p.projectId}`" class="text-decoration-none">
|
||||
{{ p.projectName }}
|
||||
</NuxtLink>
|
||||
<small v-if="p.projectCode" class="text-muted ms-1">({{ p.projectCode }})</small>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="p.projectType === 'SM' ? 'badge bg-info' : 'badge bg-primary'">
|
||||
{{ p.projectType || 'SI' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<small v-if="p.startDate">
|
||||
{{ formatDate(p.startDate) }} ~ {{ formatDate(p.endDate) || '진행중' }}
|
||||
</small>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="getProjectStatusClass(p.projectStatus)">
|
||||
{{ getProjectStatusText(p.projectStatus) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="projects.length === 0">
|
||||
<td colspan="4" class="text-center py-4 text-muted">
|
||||
소속된 프로젝트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 모달 -->
|
||||
<div class="modal fade" :class="{ show: showModal }" :style="{ display: showModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">사업 수정</h5>
|
||||
<button type="button" class="btn-close" @click="showModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">사업명 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.businessName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">사업코드</label>
|
||||
<input type="text" class="form-control" v-model="form.businessCode" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">발주처</label>
|
||||
<input type="text" class="form-control" v-model="form.clientName" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">상태</label>
|
||||
<select class="form-select" v-model="form.businessStatus">
|
||||
<option value="active">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="suspended">중단</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">계약 시작일</label>
|
||||
<input type="date" class="form-control" v-model="form.contractStartDate" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">계약 종료일</label>
|
||||
<input type="date" class="form-control" v-model="form.contractEndDate" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea class="form-control" v-model="form.description" rows="3"></textarea>
|
||||
</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="updateBusiness" :disabled="isSaving">
|
||||
<span v-if="isSaving">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>저장 중...
|
||||
</span>
|
||||
<span v-else><i class="bi bi-check-lg me-1"></i>저장</span>
|
||||
</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 route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const businessId = computed(() => Number(route.params.id))
|
||||
|
||||
const business = ref<any>(null)
|
||||
const projects = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
businessName: '',
|
||||
businessCode: '',
|
||||
clientName: '',
|
||||
contractStartDate: '',
|
||||
contractEndDate: '',
|
||||
businessStatus: 'active',
|
||||
description: ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
await loadBusiness()
|
||||
})
|
||||
|
||||
async function loadBusiness() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>(`/api/business/${businessId.value}/detail`)
|
||||
business.value = res.business
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error('Load business error:', e)
|
||||
alert('사업 정보를 불러올 수 없습니다.')
|
||||
router.push('/business')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal() {
|
||||
form.value = {
|
||||
businessName: business.value.businessName,
|
||||
businessCode: business.value.businessCode || '',
|
||||
clientName: business.value.clientName || '',
|
||||
contractStartDate: business.value.contractStartDate?.split('T')[0] || '',
|
||||
contractEndDate: business.value.contractEndDate?.split('T')[0] || '',
|
||||
businessStatus: business.value.businessStatus || 'active',
|
||||
description: business.value.description || ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function updateBusiness() {
|
||||
if (!form.value.businessName) {
|
||||
alert('사업명을 입력하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch(`/api/business/${businessId.value}/update`, {
|
||||
method: 'PUT',
|
||||
body: form.value
|
||||
})
|
||||
showModal.value = false
|
||||
await loadBusiness()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '수정에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBusiness() {
|
||||
if (!confirm('정말 삭제(중단)하시겠습니까?')) return
|
||||
|
||||
try {
|
||||
await $fetch(`/api/business/${businessId.value}/delete`, { method: 'DELETE' })
|
||||
router.push('/business')
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status: string) {
|
||||
const classes: Record<string, string> = {
|
||||
'active': 'badge bg-success',
|
||||
'completed': 'badge bg-secondary',
|
||||
'suspended': 'badge bg-danger'
|
||||
}
|
||||
return classes[status] || 'badge bg-secondary'
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
const texts: Record<string, string> = {
|
||||
'active': '진행중',
|
||||
'completed': '완료',
|
||||
'suspended': '중단'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
function getProjectStatusClass(status: string) {
|
||||
const classes: Record<string, string> = {
|
||||
'ACTIVE': 'badge bg-success',
|
||||
'COMPLETED': 'badge bg-secondary',
|
||||
'HOLD': 'badge bg-warning'
|
||||
}
|
||||
return classes[status] || 'badge bg-secondary'
|
||||
}
|
||||
|
||||
function getProjectStatusText(status: string) {
|
||||
const texts: Record<string, string> = {
|
||||
'ACTIVE': '진행중',
|
||||
'COMPLETED': '완료',
|
||||
'HOLD': '보류'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | null) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
321
frontend/business/index.vue
Normal file
321
frontend/business/index.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<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-briefcase me-2"></i>사업 관리
|
||||
</h4>
|
||||
<button class="btn btn-primary" @click="openCreateModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>새 사업
|
||||
</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">
|
||||
<input type="text" class="form-control form-control-sm" v-model="filter.businessName" @keyup.enter="loadBusinesses" />
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">발주처</label></div>
|
||||
<div class="col-2">
|
||||
<input type="text" class="form-control form-control-sm" v-model="filter.clientName" @keyup.enter="loadBusinesses" />
|
||||
</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="loadBusinesses">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusAll">전체</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusActive" value="active" v-model="filter.status" @change="loadBusinesses">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusActive">진행중</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusCompleted" value="completed" v-model="filter.status" @change="loadBusinesses">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusCompleted">완료</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusSuspended" value="suspended" v-model="filter.status" @change="loadBusinesses">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusSuspended">중단</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-primary btn-sm me-1" @click="loadBusinesses">
|
||||
<i class="bi bi-search me-1"></i>조회
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="resetSearch">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사업 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>사업 목록 총 <strong>{{ businesses.length }}</strong>건</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th style="width: 120px">사업코드</th>
|
||||
<th>사업명</th>
|
||||
<th style="width: 150px">발주처</th>
|
||||
<th style="width: 200px">계약기간</th>
|
||||
<th style="width: 80px" class="text-center">프로젝트</th>
|
||||
<th style="width: 80px" 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 me-2"></span>로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="businesses.length === 0">
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">조회된 사업이 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(biz, idx) in businesses" :key="biz.businessId">
|
||||
<td class="text-center">{{ idx + 1 }}</td>
|
||||
<td><code>{{ biz.businessCode || '-' }}</code></td>
|
||||
<td>
|
||||
<NuxtLink :to="`/business/${biz.businessId}`" class="text-decoration-none">
|
||||
{{ biz.businessName }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td>{{ biz.clientName || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="biz.contractStartDate">
|
||||
{{ formatDate(biz.contractStartDate) }} ~ {{ formatDate(biz.contractEndDate) || '진행중' }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-light text-dark">{{ biz.projectCount }}개</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span :class="getStatusBadgeClass(biz.businessStatus)">
|
||||
{{ getStatusText(biz.businessStatus) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사업 등록/수정 모달 -->
|
||||
<div class="modal fade" :class="{ show: showModal }" :style="{ display: showModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ isEdit ? '사업 수정' : '새 사업 등록' }}</h5>
|
||||
<button type="button" class="btn-close" @click="showModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">사업명 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.businessName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">사업코드</label>
|
||||
<input type="text" class="form-control" v-model="form.businessCode" placeholder="BIZ-2026-001" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">발주처</label>
|
||||
<input type="text" class="form-control" v-model="form.clientName" />
|
||||
</div>
|
||||
<div class="col-md-6" v-if="isEdit">
|
||||
<label class="form-label">상태</label>
|
||||
<select class="form-select" v-model="form.businessStatus">
|
||||
<option value="active">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="suspended">중단</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">계약 시작일</label>
|
||||
<input type="date" class="form-control" v-model="form.contractStartDate" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">계약 종료일</label>
|
||||
<input type="date" class="form-control" v-model="form.contractEndDate" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea class="form-control" v-model="form.description" rows="3"></textarea>
|
||||
</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="saveBusiness" :disabled="isSaving">
|
||||
<span v-if="isSaving">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>저장 중...
|
||||
</span>
|
||||
<span v-else><i class="bi bi-check-lg me-1"></i>저장</span>
|
||||
</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 Business {
|
||||
businessId: number
|
||||
businessName: string
|
||||
businessCode: string | null
|
||||
clientName: string | null
|
||||
contractStartDate: string | null
|
||||
contractEndDate: string | null
|
||||
businessStatus: string
|
||||
projectCount: number
|
||||
}
|
||||
|
||||
const businesses = ref<Business[]>([])
|
||||
const isLoading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
|
||||
const filter = ref({
|
||||
businessName: '',
|
||||
clientName: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
businessName: '',
|
||||
businessCode: '',
|
||||
clientName: '',
|
||||
contractStartDate: '',
|
||||
contractEndDate: '',
|
||||
businessStatus: 'active',
|
||||
description: ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
await loadBusinesses()
|
||||
})
|
||||
|
||||
async function loadBusinesses() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ businesses: Business[] }>('/api/business/list', {
|
||||
query: {
|
||||
businessName: filter.value.businessName || undefined,
|
||||
clientName: filter.value.clientName || undefined,
|
||||
status: filter.value.status || undefined
|
||||
}
|
||||
})
|
||||
businesses.value = res.businesses || []
|
||||
} catch (e) {
|
||||
console.error('Load businesses error:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
filter.value = {
|
||||
businessName: '',
|
||||
clientName: '',
|
||||
status: ''
|
||||
}
|
||||
loadBusinesses()
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
isEdit.value = false
|
||||
editId.value = null
|
||||
form.value = {
|
||||
businessName: '',
|
||||
businessCode: '',
|
||||
clientName: '',
|
||||
contractStartDate: '',
|
||||
contractEndDate: '',
|
||||
businessStatus: 'active',
|
||||
description: ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveBusiness() {
|
||||
if (!form.value.businessName) {
|
||||
alert('사업명을 입력하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (isEdit.value && editId.value) {
|
||||
await $fetch(`/api/business/${editId.value}/update`, {
|
||||
method: 'PUT',
|
||||
body: form.value
|
||||
})
|
||||
} else {
|
||||
await $fetch('/api/business/create', {
|
||||
method: 'POST',
|
||||
body: form.value
|
||||
})
|
||||
}
|
||||
showModal.value = false
|
||||
await loadBusinesses()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status: string) {
|
||||
const classes: Record<string, string> = {
|
||||
'active': 'badge bg-success',
|
||||
'completed': 'badge bg-secondary',
|
||||
'suspended': 'badge bg-danger'
|
||||
}
|
||||
return classes[status] || 'badge bg-secondary'
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
const texts: Record<string, string> = {
|
||||
'active': '진행중',
|
||||
'completed': '완료',
|
||||
'suspended': '중단'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
468
frontend/maintenance/[id].vue
Normal file
468
frontend/maintenance/[id].vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<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-tools me-2"></i>유지보수 업무 상세
|
||||
</h4>
|
||||
<div>
|
||||
<NuxtLink to="/maintenance" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-arrow-left me-1"></i>목록
|
||||
</NuxtLink>
|
||||
<button class="btn btn-danger" @click="deleteTask" v-if="!isEditing">
|
||||
<i class="bi bi-trash me-1"></i>삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="task" class="row">
|
||||
<!-- 왼쪽: 기본 정보 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>업무 정보</strong>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="isEditing ? 'btn-secondary' : 'btn-outline-primary'"
|
||||
@click="toggleEdit"
|
||||
>
|
||||
<i :class="isEditing ? 'bi bi-x-lg' : 'bi bi-pencil'" class="me-1"></i>
|
||||
{{ isEditing ? '취소' : '수정' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 보기 모드 -->
|
||||
<div v-if="!isEditing">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small">프로젝트</label>
|
||||
<p class="mb-0">{{ task.projectName || '-' }}</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small">요청일</label>
|
||||
<p class="mb-0">{{ formatDate(task.requestDate) }}</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small">담당자</label>
|
||||
<p class="mb-0">{{ task.assigneeName || '미배정' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<label class="text-muted small">제목</label>
|
||||
<p class="mb-0 fw-bold">{{ task.requestTitle }}</p>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="text-muted small">우선순위</label>
|
||||
<p class="mb-0">
|
||||
<span :class="getPriorityBadgeClass(task.priority)">{{ getPriorityText(task.priority) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="text-muted small">상태</label>
|
||||
<p class="mb-0">
|
||||
<span :class="getStatusBadgeClass(task.status)">{{ getStatusText(task.status) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small">요청자</label>
|
||||
<p class="mb-0">{{ task.requesterName || '-' }} {{ task.requesterContact ? `(${task.requesterContact})` : '' }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small">업무유형</label>
|
||||
<p class="mb-0">{{ getTaskTypeText(task.taskType) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" v-if="task.requestContent">
|
||||
<label class="text-muted small">요청 내용</label>
|
||||
<div class="border rounded p-3 bg-light" style="white-space: pre-wrap;">{{ task.requestContent }}</div>
|
||||
</div>
|
||||
<div class="mb-0" v-if="task.resolutionContent">
|
||||
<label class="text-muted small">처리 내용</label>
|
||||
<div class="border rounded p-3 bg-light" style="white-space: pre-wrap;">{{ task.resolutionContent }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 모드 -->
|
||||
<div v-else>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<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-md-4">
|
||||
<label class="form-label">요청일</label>
|
||||
<input type="date" class="form-control" v-model="form.requestDate" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<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 class="col-md-6">
|
||||
<label class="form-label">제목</label>
|
||||
<input type="text" class="form-control" v-model="form.requestTitle" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">우선순위</label>
|
||||
<select class="form-select" v-model="form.priority">
|
||||
<option value="HIGH">높음</option>
|
||||
<option value="MEDIUM">보통</option>
|
||||
<option value="LOW">낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">요청자명</label>
|
||||
<input type="text" class="form-control" v-model="form.requesterName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">요청자 연락처</label>
|
||||
<input type="text" class="form-control" v-model="form.requesterContact" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">업무유형</label>
|
||||
<select class="form-select" v-model="form.taskType">
|
||||
<option value="GENERAL">일반</option>
|
||||
<option value="BUG">버그수정</option>
|
||||
<option value="ENHANCEMENT">기능개선</option>
|
||||
<option value="DATA">데이터</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">요청 내용</label>
|
||||
<textarea class="form-control" v-model="form.requestContent" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">처리 내용</label>
|
||||
<textarea class="form-control" v-model="form.resolutionContent" rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-end">
|
||||
<button class="btn btn-primary" @click="saveTask" :disabled="isSaving">
|
||||
<span v-if="isSaving"><span class="spinner-border spinner-border-sm me-1"></span>저장 중...</span>
|
||||
<span v-else><i class="bi bi-check-lg me-1"></i>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 반영 상태 + 메타 -->
|
||||
<div class="col-md-4">
|
||||
<!-- 상태 변경 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>상태 변경</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<button
|
||||
v-for="s in statusOptions"
|
||||
:key="s.value"
|
||||
class="btn"
|
||||
:class="task.status === s.value ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@click="changeStatus(s.value)"
|
||||
:disabled="isChangingStatus"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반영 체크 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>반영 현황</strong></div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-server me-2"></i>개발서버 반영</span>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input me-2"
|
||||
:checked="!!task.devCompletedAt"
|
||||
@change="toggleDeploy('dev', $event)"
|
||||
/>
|
||||
<small class="text-muted">{{ formatDate(task.devCompletedAt) || '-' }}</small>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-cloud me-2"></i>운영서버 반영</span>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input me-2"
|
||||
:checked="!!task.opsCompletedAt"
|
||||
@change="toggleDeploy('ops', $event)"
|
||||
/>
|
||||
<small class="text-muted">{{ formatDate(task.opsCompletedAt) || '-' }}</small>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-person-check me-2"></i>고객 확인</span>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input me-2"
|
||||
:checked="!!task.clientConfirmedAt"
|
||||
@change="toggleDeploy('client', $event)"
|
||||
/>
|
||||
<small class="text-muted">{{ formatDate(task.clientConfirmedAt) || '-' }}</small>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 메타 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>등록 정보</strong></div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">등록자</span>
|
||||
<span>{{ task.createdByName || '-' }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">등록일</span>
|
||||
<span>{{ formatDateTime(task.createdAt) }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">수정자</span>
|
||||
<span>{{ task.updatedByName || '-' }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">수정일</span>
|
||||
<span>{{ formatDateTime(task.updatedAt) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const taskId = computed(() => Number(route.params.id))
|
||||
|
||||
const task = ref<any>(null)
|
||||
const projects = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isChangingStatus = ref(false)
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'PENDING', label: '미진행' },
|
||||
{ value: 'IN_PROGRESS', label: '진행중' },
|
||||
{ value: 'COMPLETED', label: '완료' }
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
projectId: '',
|
||||
requestDate: '',
|
||||
requestTitle: '',
|
||||
requestContent: '',
|
||||
requesterName: '',
|
||||
requesterContact: '',
|
||||
taskType: 'GENERAL',
|
||||
priority: 'MEDIUM',
|
||||
status: 'PENDING',
|
||||
assigneeId: '',
|
||||
resolutionContent: '',
|
||||
devCompletedAt: '',
|
||||
opsCompletedAt: '',
|
||||
clientConfirmedAt: ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
await Promise.all([loadTask(), loadProjects(), loadEmployees()])
|
||||
})
|
||||
|
||||
async function loadTask() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>(`/api/maintenance/${taskId.value}/detail`)
|
||||
task.value = res.task
|
||||
} catch (e) {
|
||||
console.error('Load task error:', e)
|
||||
alert('업무 정보를 불러올 수 없습니다.')
|
||||
router.push('/maintenance')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<any>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const res = await $fetch<any>('/api/employee/list')
|
||||
employees.value = res.employees || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (!isEditing.value) {
|
||||
form.value = {
|
||||
projectId: task.value.projectId?.toString() || '',
|
||||
requestDate: task.value.requestDate?.split('T')[0] || '',
|
||||
requestTitle: task.value.requestTitle || '',
|
||||
requestContent: task.value.requestContent || '',
|
||||
requesterName: task.value.requesterName || '',
|
||||
requesterContact: task.value.requesterContact || '',
|
||||
taskType: task.value.taskType || 'GENERAL',
|
||||
priority: task.value.priority || 'MEDIUM',
|
||||
status: task.value.status || 'PENDING',
|
||||
assigneeId: task.value.assigneeId?.toString() || '',
|
||||
resolutionContent: task.value.resolutionContent || '',
|
||||
devCompletedAt: task.value.devCompletedAt?.split('T')[0] || '',
|
||||
opsCompletedAt: task.value.opsCompletedAt?.split('T')[0] || '',
|
||||
clientConfirmedAt: task.value.clientConfirmedAt?.split('T')[0] || ''
|
||||
}
|
||||
}
|
||||
isEditing.value = !isEditing.value
|
||||
}
|
||||
|
||||
async function saveTask() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch(`/api/maintenance/${taskId.value}/update`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
projectId: form.value.projectId ? Number(form.value.projectId) : null,
|
||||
requestDate: form.value.requestDate,
|
||||
requestTitle: form.value.requestTitle,
|
||||
requestContent: form.value.requestContent,
|
||||
requesterName: form.value.requesterName,
|
||||
requesterContact: form.value.requesterContact,
|
||||
taskType: form.value.taskType,
|
||||
priority: form.value.priority,
|
||||
status: form.value.status,
|
||||
assigneeId: form.value.assigneeId ? Number(form.value.assigneeId) : null,
|
||||
resolutionContent: form.value.resolutionContent,
|
||||
devCompletedAt: form.value.devCompletedAt || null,
|
||||
opsCompletedAt: form.value.opsCompletedAt || null,
|
||||
clientConfirmedAt: form.value.clientConfirmedAt || null
|
||||
}
|
||||
})
|
||||
isEditing.value = false
|
||||
await loadTask()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStatus(status: string) {
|
||||
if (task.value.status === status) return
|
||||
isChangingStatus.value = true
|
||||
try {
|
||||
await $fetch(`/api/maintenance/${taskId.value}/status`, {
|
||||
method: 'PUT',
|
||||
body: { status }
|
||||
})
|
||||
task.value.status = status
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '상태 변경에 실패했습니다.')
|
||||
} finally {
|
||||
isChangingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDeploy(type: string, event: Event) {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
'dev': 'devCompletedAt',
|
||||
'ops': 'opsCompletedAt',
|
||||
'client': 'clientConfirmedAt'
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch(`/api/maintenance/${taskId.value}/update`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...task.value,
|
||||
projectId: task.value.projectId,
|
||||
[fieldMap[type]]: checked ? now : null
|
||||
}
|
||||
})
|
||||
task.value[fieldMap[type]] = checked ? now : null
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '반영 상태 변경에 실패했습니다.')
|
||||
;(event.target as HTMLInputElement).checked = !checked
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask() {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await $fetch(`/api/maintenance/${taskId.value}/delete`, { method: 'DELETE' })
|
||||
router.push('/maintenance')
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityBadgeClass(p: string) {
|
||||
return { 'HIGH': 'badge bg-danger', 'MEDIUM': 'badge bg-warning text-dark', 'LOW': 'badge bg-secondary' }[p] || 'badge bg-secondary'
|
||||
}
|
||||
function getPriorityText(p: string) {
|
||||
return { 'HIGH': '높음', 'MEDIUM': '보통', 'LOW': '낮음' }[p] || p
|
||||
}
|
||||
function getStatusBadgeClass(s: string) {
|
||||
return { 'PENDING': 'badge bg-secondary', 'IN_PROGRESS': 'badge bg-primary', 'COMPLETED': 'badge bg-success' }[s] || 'badge bg-secondary'
|
||||
}
|
||||
function getStatusText(s: string) {
|
||||
return { 'PENDING': '미진행', 'IN_PROGRESS': '진행중', 'COMPLETED': '완료' }[s] || s
|
||||
}
|
||||
function getTaskTypeText(t: string) {
|
||||
return { 'GENERAL': '일반', 'BUG': '버그수정', 'ENHANCEMENT': '기능개선', 'DATA': '데이터' }[t] || t
|
||||
}
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return ''
|
||||
return new Date(d).toISOString().split('T')[0]
|
||||
}
|
||||
function formatDateTime(d: string | null) {
|
||||
if (!d) return '-'
|
||||
const dt = new Date(d)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')} ${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
347
frontend/maintenance/index.vue
Normal file
347
frontend/maintenance/index.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<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-tools me-2"></i>유지보수 업무
|
||||
</h4>
|
||||
<div>
|
||||
<NuxtLink to="/maintenance/upload" class="btn btn-outline-primary me-2">
|
||||
<i class="bi bi-upload me-1"></i>일괄 등록
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/maintenance/write" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>업무 등록
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
v-model="filter.keyword"
|
||||
placeholder="제목, 내용, 요청자"
|
||||
@keyup.enter="loadTasks"
|
||||
/>
|
||||
</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="loadTasks">
|
||||
<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="loadTasks">
|
||||
<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="loadTasks">
|
||||
<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="loadTasks">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusCompleted">완료</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 align-items-center mt-1">
|
||||
<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.priority">
|
||||
<option value="">전체</option>
|
||||
<option value="HIGH">높음</option>
|
||||
<option value="MEDIUM">보통</option>
|
||||
<option value="LOW">낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">요청일</label></div>
|
||||
<div class="col-2">
|
||||
<input type="date" class="form-control form-control-sm" v-model="filter.startDate" />
|
||||
</div>
|
||||
<div class="col-auto px-1">~</div>
|
||||
<div class="col-2">
|
||||
<input type="date" class="form-control form-control-sm" v-model="filter.endDate" />
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-primary btn-sm me-1" @click="loadTasks">
|
||||
<i class="bi bi-search me-1"></i>조회
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="resetSearch">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>유지보수 업무 목록 총 <strong>{{ pagination.total }}</strong>건</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th style="width: 100px">요청일</th>
|
||||
<th>제목</th>
|
||||
<th style="width: 120px">프로젝트</th>
|
||||
<th style="width: 80px">요청자</th>
|
||||
<th style="width: 70px" class="text-center">우선순위</th>
|
||||
<th style="width: 80px" class="text-center">상태</th>
|
||||
<th style="width: 80px">담당자</th>
|
||||
<th style="width: 50px" class="text-center" title="개발서버">개발</th>
|
||||
<th style="width: 50px" class="text-center" title="운영서버">운영</th>
|
||||
<th style="width: 50px" class="text-center" title="고객확인">확인</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="11" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="tasks.length === 0">
|
||||
<td colspan="11" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">조회된 업무가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(task, idx) in tasks" :key="task.taskId">
|
||||
<td class="text-center">{{ (pagination.page - 1) * pagination.pageSize + idx + 1 }}</td>
|
||||
<td>{{ formatDate(task.requestDate) }}</td>
|
||||
<td>
|
||||
<NuxtLink :to="`/maintenance/${task.taskId}`" class="text-decoration-none">
|
||||
{{ task.requestTitle }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td>{{ task.projectName || '-' }}</td>
|
||||
<td>{{ task.requesterName || '-' }}</td>
|
||||
<td class="text-center">
|
||||
<span :class="getPriorityBadgeClass(task.priority)">
|
||||
{{ getPriorityText(task.priority) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span :class="getStatusBadgeClass(task.status)">
|
||||
{{ getStatusText(task.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ task.assigneeName || '-' }}</td>
|
||||
<td class="text-center">
|
||||
<i v-if="task.devCompletedAt" class="bi bi-check-circle-fill text-success"></i>
|
||||
<i v-else class="bi bi-circle text-muted"></i>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<i v-if="task.opsCompletedAt" class="bi bi-check-circle-fill text-success"></i>
|
||||
<i v-else class="bi bi-circle text-muted"></i>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<i v-if="task.clientConfirmedAt" class="bi bi-check-circle-fill text-success"></i>
|
||||
<i v-else class="bi bi-circle text-muted"></i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
<div class="card-footer d-flex justify-content-between align-items-center" v-if="pagination.totalPages > 1">
|
||||
<small class="text-muted">
|
||||
전체 {{ pagination.total }}건 중 {{ (pagination.page - 1) * pagination.pageSize + 1 }} -
|
||||
{{ Math.min(pagination.page * pagination.pageSize, pagination.total) }}건
|
||||
</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item" :class="{ disabled: pagination.page === 1 }">
|
||||
<a class="page-link" href="#" @click.prevent="goPage(pagination.page - 1)">이전</a>
|
||||
</li>
|
||||
<li
|
||||
class="page-item"
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === pagination.page }"
|
||||
>
|
||||
<a class="page-link" href="#" @click.prevent="goPage(p)">{{ p }}</a>
|
||||
</li>
|
||||
<li class="page-item" :class="{ disabled: pagination.page === pagination.totalPages }">
|
||||
<a class="page-link" href="#" @click.prevent="goPage(pagination.page + 1)">다음</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Task {
|
||||
taskId: number
|
||||
projectId: number | null
|
||||
projectName: string | null
|
||||
requestDate: string
|
||||
requestTitle: string
|
||||
requesterName: string | null
|
||||
priority: string
|
||||
status: string
|
||||
assigneeId: number | null
|
||||
assigneeName: string | null
|
||||
devCompletedAt: string | null
|
||||
opsCompletedAt: string | null
|
||||
clientConfirmedAt: string | null
|
||||
}
|
||||
|
||||
interface Project {
|
||||
projectId: number
|
||||
projectName: string
|
||||
}
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const filter = ref({
|
||||
projectId: '',
|
||||
keyword: '',
|
||||
status: '',
|
||||
priority: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: number[] = []
|
||||
const total = pagination.value.totalPages
|
||||
const current = pagination.value.page
|
||||
let start = Math.max(1, current - 2)
|
||||
let end = Math.min(total, current + 2)
|
||||
if (end - start < 4) {
|
||||
if (start === 1) end = Math.min(total, 5)
|
||||
else start = Math.max(1, total - 4)
|
||||
}
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
return pages
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
await loadProjects()
|
||||
await loadTasks()
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error('Load projects error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>('/api/maintenance/list', {
|
||||
query: {
|
||||
projectId: filter.value.projectId || undefined,
|
||||
keyword: filter.value.keyword || undefined,
|
||||
status: filter.value.status || undefined,
|
||||
priority: filter.value.priority || undefined,
|
||||
startDate: filter.value.startDate || undefined,
|
||||
endDate: filter.value.endDate || undefined,
|
||||
page: pagination.value.page,
|
||||
pageSize: pagination.value.pageSize
|
||||
}
|
||||
})
|
||||
tasks.value = res.tasks || []
|
||||
pagination.value.total = res.pagination?.total || 0
|
||||
pagination.value.totalPages = res.pagination?.totalPages || 0
|
||||
} catch (e) {
|
||||
console.error('Load tasks error:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
filter.value = {
|
||||
projectId: '',
|
||||
keyword: '',
|
||||
status: '',
|
||||
priority: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
}
|
||||
pagination.value.page = 1
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
function goPage(page: number) {
|
||||
if (page < 1 || page > pagination.value.totalPages) return
|
||||
pagination.value.page = page
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
function getPriorityBadgeClass(priority: string) {
|
||||
const classes: Record<string, string> = {
|
||||
'HIGH': 'badge bg-danger',
|
||||
'MEDIUM': 'badge bg-warning text-dark',
|
||||
'LOW': 'badge bg-secondary'
|
||||
}
|
||||
return classes[priority] || 'badge bg-secondary'
|
||||
}
|
||||
|
||||
function getPriorityText(priority: string) {
|
||||
const texts: Record<string, string> = { 'HIGH': '높음', 'MEDIUM': '보통', 'LOW': '낮음' }
|
||||
return texts[priority] || priority
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status: string) {
|
||||
const classes: Record<string, string> = {
|
||||
'PENDING': 'badge bg-secondary',
|
||||
'IN_PROGRESS': 'badge bg-primary',
|
||||
'COMPLETED': 'badge bg-success'
|
||||
}
|
||||
return classes[status] || 'badge bg-secondary'
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
const texts: Record<string, string> = { 'PENDING': '미진행', 'IN_PROGRESS': '진행중', 'COMPLETED': '완료' }
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
251
frontend/maintenance/upload.vue
Normal file
251
frontend/maintenance/upload.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<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-upload me-2"></i>유지보수 업무 일괄 등록
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: 파일 업로드 -->
|
||||
<div class="card mb-4" v-if="step === 1">
|
||||
<div class="card-header"><strong>Step 1. 파일 업로드</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">프로젝트 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" v-model="projectId">
|
||||
<option value="">선택하세요</option>
|
||||
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">엑셀/CSV 파일 <span class="text-danger">*</span></label>
|
||||
<input type="file" class="form-control" @change="onFileChange" accept=".xlsx,.xls,.csv" ref="fileInput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mt-3">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
엑셀 파일의 첫 번째 행은 헤더여야 합니다. AI가 자동으로 컬럼을 매핑합니다.
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-primary" @click="uploadFile" :disabled="!projectId || !selectedFile || isUploading">
|
||||
<span v-if="isUploading"><span class="spinner-border spinner-border-sm me-1"></span>분석 중...</span>
|
||||
<span v-else><i class="bi bi-cloud-upload me-1"></i>업로드 및 분석</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 파싱 결과 검토 -->
|
||||
<div class="card mb-4" v-if="step === 2">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Step 2. 파싱 결과 검토</strong>
|
||||
<span class="text-muted">{{ uploadResult?.filename }} - {{ uploadResult?.totalRows }}건</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 40px" class="text-center">
|
||||
<input type="checkbox" v-model="selectAll" @change="toggleSelectAll" />
|
||||
</th>
|
||||
<th style="width: 40px" class="text-center">행</th>
|
||||
<th style="width: 100px">요청일</th>
|
||||
<th>요청 제목</th>
|
||||
<th style="width: 80px">유형</th>
|
||||
<th style="width: 80px">우선순위</th>
|
||||
<th style="width: 80px">요청자</th>
|
||||
<th style="width: 80px" class="text-center">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(task, idx) in parsedTasks" :key="idx" :class="{ 'table-warning': task.isDuplicate }">
|
||||
<td class="text-center">
|
||||
<input type="checkbox" v-model="task.selected" :disabled="task.isDuplicate" />
|
||||
</td>
|
||||
<td class="text-center">{{ task.rowIndex }}</td>
|
||||
<td>
|
||||
<input type="date" class="form-control form-control-sm" v-model="task.requestDate" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm" v-model="task.requestTitle" />
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" v-model="task.taskType">
|
||||
<option value="bug">버그</option>
|
||||
<option value="feature">기능</option>
|
||||
<option value="inquiry">문의</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" v-model="task.priority">
|
||||
<option value="high">높음</option>
|
||||
<option value="medium">중간</option>
|
||||
<option value="low">낮음</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>{{ task.requesterName || '-' }}</td>
|
||||
<td class="text-center">
|
||||
<span v-if="task.isDuplicate" class="badge bg-danger">중복</span>
|
||||
<span v-else class="badge bg-success">신규</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
<button class="btn btn-secondary" @click="step = 1">
|
||||
<i class="bi bi-arrow-left me-1"></i>이전
|
||||
</button>
|
||||
<div>
|
||||
<span class="me-3">선택: {{ selectedCount }}건 / 중복: {{ duplicateCount }}건</span>
|
||||
<button class="btn btn-primary" @click="bulkCreate" :disabled="selectedCount === 0 || isCreating">
|
||||
<span v-if="isCreating"><span class="spinner-border spinner-border-sm me-1"></span>등록 중...</span>
|
||||
<span v-else><i class="bi bi-check-lg me-1"></i>{{ selectedCount }}건 등록</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 완료 -->
|
||||
<div class="card" v-if="step === 3">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-check-circle text-success display-1"></i>
|
||||
<h4 class="mt-3">등록 완료!</h4>
|
||||
<p class="text-muted">{{ createResult?.insertedCount }}건이 등록되었습니다.</p>
|
||||
<div class="mt-4">
|
||||
<NuxtLink to="/maintenance" class="btn btn-primary me-2">
|
||||
<i class="bi bi-list me-1"></i>목록으로
|
||||
</NuxtLink>
|
||||
<button class="btn btn-outline-secondary" @click="resetAll">
|
||||
<i class="bi bi-plus-lg me-1"></i>추가 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Project { projectId: number; projectName: string }
|
||||
interface ParsedTask {
|
||||
rowIndex: number
|
||||
requestDate: string | null
|
||||
requestTitle: string
|
||||
requestContent: string | null
|
||||
requesterName: string | null
|
||||
requesterContact: string | null
|
||||
taskType: string
|
||||
priority: string
|
||||
resolutionContent: string | null
|
||||
isDuplicate: boolean
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const projectId = ref('')
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const step = ref(1)
|
||||
const isUploading = ref(false)
|
||||
const isCreating = ref(false)
|
||||
|
||||
const uploadResult = ref<any>(null)
|
||||
const parsedTasks = ref<ParsedTask[]>([])
|
||||
const createResult = ref<any>(null)
|
||||
const selectAll = ref(true)
|
||||
|
||||
const selectedCount = computed(() => parsedTasks.value.filter(t => t.selected && !t.isDuplicate).length)
|
||||
const duplicateCount = computed(() => parsedTasks.value.filter(t => t.isDuplicate).length)
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
await loadProjects()
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
selectedFile.value = target.files?.[0] || null
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!projectId.value || !selectedFile.value) return
|
||||
|
||||
isUploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
formData.append('projectId', projectId.value)
|
||||
|
||||
const res = await $fetch<any>('/api/maintenance/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
uploadResult.value = res
|
||||
parsedTasks.value = res.tasks.map((t: any) => ({ ...t, selected: !t.isDuplicate }))
|
||||
step.value = 2
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '파일 분석에 실패했습니다.')
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
parsedTasks.value.forEach(t => {
|
||||
if (!t.isDuplicate) t.selected = selectAll.value
|
||||
})
|
||||
}
|
||||
|
||||
async function bulkCreate() {
|
||||
if (selectedCount.value === 0) return
|
||||
|
||||
isCreating.value = true
|
||||
try {
|
||||
const res = await $fetch<any>('/api/maintenance/bulk-create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
projectId: Number(projectId.value),
|
||||
batchId: uploadResult.value.batchId,
|
||||
tasks: parsedTasks.value.filter(t => t.selected && !t.isDuplicate)
|
||||
}
|
||||
})
|
||||
createResult.value = res
|
||||
step.value = 3
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '등록에 실패했습니다.')
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
step.value = 1
|
||||
selectedFile.value = null
|
||||
uploadResult.value = null
|
||||
parsedTasks.value = []
|
||||
createResult.value = null
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
</script>
|
||||
172
frontend/maintenance/write.vue
Normal file
172
frontend/maintenance/write.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<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-tools me-2"></i>유지보수 업무 등록
|
||||
</h4>
|
||||
<NuxtLink to="/maintenance" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>목록
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">프로젝트</label>
|
||||
<select class="form-select" v-model="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-md-4">
|
||||
<label class="form-label">요청일 <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control" v-model="form.requestDate" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<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 class="col-md-8">
|
||||
<label class="form-label">제목 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.requestTitle" placeholder="업무 제목" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">우선순위</label>
|
||||
<select class="form-select" v-model="form.priority">
|
||||
<option value="HIGH">높음</option>
|
||||
<option value="MEDIUM">보통</option>
|
||||
<option value="LOW">낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">업무유형</label>
|
||||
<select class="form-select" v-model="form.taskType">
|
||||
<option value="GENERAL">일반</option>
|
||||
<option value="BUG">버그수정</option>
|
||||
<option value="ENHANCEMENT">기능개선</option>
|
||||
<option value="DATA">데이터</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">요청자명</label>
|
||||
<input type="text" class="form-control" v-model="form.requesterName" placeholder="요청자 이름" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">요청자 연락처</label>
|
||||
<input type="text" class="form-control" v-model="form.requesterContact" placeholder="전화번호 또는 이메일" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">내용</label>
|
||||
<textarea class="form-control" v-model="form.requestContent" rows="6" placeholder="업무 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<button class="btn btn-primary" @click="save" :disabled="isSaving">
|
||||
<span v-if="isSaving">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>등록 중...
|
||||
</span>
|
||||
<span v-else><i class="bi bi-check-lg me-1"></i>등록</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Project { projectId: number; projectName: string }
|
||||
interface Employee { employeeId: number; employeeName: string }
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const employees = ref<Employee[]>([])
|
||||
const isSaving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
projectId: '',
|
||||
requestDate: new Date().toISOString().split('T')[0],
|
||||
requestTitle: '',
|
||||
requestContent: '',
|
||||
requesterName: '',
|
||||
requesterContact: '',
|
||||
taskType: 'GENERAL',
|
||||
priority: 'MEDIUM',
|
||||
assigneeId: ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
await loadProjects()
|
||||
await loadEmployees()
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error('Load projects error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const res = await $fetch<{ employees: Employee[] }>('/api/employee/list')
|
||||
employees.value = res.employees || []
|
||||
} catch (e) {
|
||||
console.error('Load employees error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.requestTitle) {
|
||||
alert('제목을 입력하세요.')
|
||||
return
|
||||
}
|
||||
if (!form.value.requestDate) {
|
||||
alert('요청일을 선택하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const res = await $fetch<{ taskId: number }>('/api/maintenance/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
projectId: form.value.projectId ? Number(form.value.projectId) : null,
|
||||
requestDate: form.value.requestDate,
|
||||
requestTitle: form.value.requestTitle,
|
||||
requestContent: form.value.requestContent,
|
||||
requesterName: form.value.requesterName,
|
||||
requesterContact: form.value.requesterContact,
|
||||
taskType: form.value.taskType,
|
||||
priority: form.value.priority,
|
||||
assigneeId: form.value.assigneeId ? Number(form.value.assigneeId) : null
|
||||
}
|
||||
})
|
||||
router.push(`/maintenance/${res.taskId}`)
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '등록에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
437
frontend/meeting/[id].vue
Normal file
437
frontend/meeting/[id].vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4>
|
||||
<i class="bi bi-journal-text me-2"></i>
|
||||
{{ isEditing ? '회의록 수정' : '회의록 상세' }}
|
||||
</h4>
|
||||
<p class="text-muted mb-0">
|
||||
{{ meeting?.meetingTitle }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NuxtLink to="/meeting" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-arrow-left me-1"></i> 목록
|
||||
</NuxtLink>
|
||||
<button v-if="!isEditing" class="btn btn-primary" @click="isEditing = true">
|
||||
<i class="bi bi-pencil me-1"></i> 수정
|
||||
</button>
|
||||
<button v-if="!isEditing" class="btn btn-outline-danger ms-2" @click="deleteMeeting">
|
||||
<i class="bi bi-trash me-1"></i> 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
<p class="mt-2 text-muted">로딩 중...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="meeting" class="row">
|
||||
<!-- 왼쪽: 기본 정보 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>기본 정보</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 제목</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="form.meetingTitle"
|
||||
/>
|
||||
<p v-else class="form-control-plaintext">{{ meeting.meetingTitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 유형</label>
|
||||
<div v-if="isEditing" class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" id="type-project" value="PROJECT" v-model="form.meetingType" />
|
||||
<label class="btn btn-outline-primary" for="type-project">프로젝트</label>
|
||||
<input type="radio" class="btn-check" id="type-internal" value="INTERNAL" v-model="form.meetingType" />
|
||||
<label class="btn btn-outline-info" for="type-internal">내부업무</label>
|
||||
</div>
|
||||
<p v-else class="form-control-plaintext">
|
||||
<span :class="meeting.meetingType === 'PROJECT' ? 'badge bg-primary' : 'badge bg-info'">
|
||||
{{ meeting.meetingType === 'PROJECT' ? '프로젝트 회의' : '내부업무 회의' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" v-if="form.meetingType === 'PROJECT' || meeting.projectName">
|
||||
<label class="form-label">프로젝트</label>
|
||||
<select v-if="isEditing && form.meetingType === 'PROJECT'" 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>
|
||||
<p v-else class="form-control-plaintext">{{ meeting.projectName || '-' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 일자</label>
|
||||
<input v-if="isEditing" type="date" class="form-control" v-model="form.meetingDate" />
|
||||
<p v-else class="form-control-plaintext">{{ formatDate(meeting.meetingDate) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">시작</label>
|
||||
<input v-if="isEditing" type="time" class="form-control" v-model="form.startTime" />
|
||||
<p v-else class="form-control-plaintext">{{ meeting.startTime?.slice(0,5) || '-' }}</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">종료</label>
|
||||
<input v-if="isEditing" type="time" class="form-control" v-model="form.endTime" />
|
||||
<p v-else class="form-control-plaintext">{{ meeting.endTime?.slice(0,5) || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">장소</label>
|
||||
<input v-if="isEditing" type="text" class="form-control" v-model="form.location" />
|
||||
<p v-else class="form-control-plaintext">{{ meeting.location || '-' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
<label class="form-label">작성자</label>
|
||||
<p class="form-control-plaintext">{{ meeting.authorName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 참석자 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>참석자 ({{ attendees.length }}명)</strong>
|
||||
<div v-if="isEditing">
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @click="showEmployeeModal = true">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="addExternalAttendee">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li v-for="(att, idx) in displayAttendees" :key="idx" class="list-group-item d-flex justify-content-between">
|
||||
<div>
|
||||
<i :class="att.isExternal ? 'bi bi-person text-secondary' : 'bi bi-person-fill text-primary'" class="me-1"></i>
|
||||
{{ att.isExternal ? att.externalName : att.employeeName }}
|
||||
<small v-if="att.isExternal && att.externalCompany" class="text-muted">({{ att.externalCompany }})</small>
|
||||
<small v-if="!att.isExternal && att.company" class="text-muted">({{ att.company }})</small>
|
||||
</div>
|
||||
<button v-if="isEditing" class="btn btn-sm btn-link text-danger" @click="removeAttendee(idx)">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="displayAttendees.length === 0" class="list-group-item text-center text-muted">
|
||||
참석자 없음
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 회의 내용 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<strong>회의 내용</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<textarea
|
||||
v-if="isEditing"
|
||||
class="form-control border-0 h-100"
|
||||
v-model="form.rawContent"
|
||||
style="min-height: 500px; resize: none;"
|
||||
></textarea>
|
||||
<div v-else class="p-3" style="min-height: 500px; white-space: pre-wrap;">{{ meeting.rawContent || '(내용 없음)' }}</div>
|
||||
</div>
|
||||
<div v-if="isEditing" class="card-footer d-flex justify-content-end">
|
||||
<button class="btn btn-secondary me-2" @click="cancelEdit">취소</button>
|
||||
<button class="btn btn-primary" @click="updateMeeting" :disabled="isSaving">
|
||||
<span v-if="isSaving">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>저장 중...
|
||||
</span>
|
||||
<span v-else><i class="bi bi-check-lg me-1"></i>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 직원 선택 모달 -->
|
||||
<div class="modal fade" :class="{ show: showEmployeeModal }" :style="{ display: showEmployeeModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">내부 참석자 추가</h5>
|
||||
<button type="button" class="btn-close" @click="showEmployeeModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="form-control mb-3" v-model="employeeSearch" placeholder="이름 검색..." />
|
||||
<div style="max-height: 300px; overflow-y: auto;">
|
||||
<div v-for="emp in filteredEmployees" :key="emp.employeeId" class="form-check">
|
||||
<input class="form-check-input" type="checkbox" :id="`emp-${emp.employeeId}`" :value="emp.employeeId" v-model="selectedEmployeeIds" />
|
||||
<label class="form-check-label" :for="`emp-${emp.employeeId}`">
|
||||
{{ emp.employeeName }} <small class="text-muted">({{ emp.company }})</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showEmployeeModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="addSelectedEmployees">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showEmployeeModal"></div>
|
||||
|
||||
<!-- 외부 참석자 모달 -->
|
||||
<div class="modal fade" :class="{ show: showExternalModal }" :style="{ display: showExternalModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">외부 참석자 추가</h5>
|
||||
<button type="button" class="btn-close" @click="showExternalModal = 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="externalForm.name" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">소속</label>
|
||||
<input type="text" class="form-control" v-model="externalForm.company" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showExternalModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="confirmExternalAttendee">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showExternalModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const meetingId = computed(() => Number(route.params.id))
|
||||
|
||||
const meeting = ref<any>(null)
|
||||
const attendees = ref<any[]>([])
|
||||
const agendas = ref<any[]>([])
|
||||
const projects = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
meetingTitle: '',
|
||||
meetingType: 'PROJECT' as 'PROJECT' | 'INTERNAL',
|
||||
projectId: '' as string | number,
|
||||
meetingDate: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
location: '',
|
||||
rawContent: '',
|
||||
attendees: [] as any[]
|
||||
})
|
||||
|
||||
const displayAttendees = computed(() => isEditing.value ? form.value.attendees : attendees.value)
|
||||
|
||||
// 모달
|
||||
const showEmployeeModal = ref(false)
|
||||
const showExternalModal = ref(false)
|
||||
const employeeSearch = ref('')
|
||||
const selectedEmployeeIds = ref<number[]>([])
|
||||
const externalForm = ref({ name: '', company: '' })
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
if (!employeeSearch.value) return employees.value
|
||||
return employees.value.filter(e => e.employeeName.toLowerCase().includes(employeeSearch.value.toLowerCase()))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all([loadMeeting(), loadProjects(), loadEmployees()])
|
||||
})
|
||||
|
||||
async function loadMeeting() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>(`/api/meeting/${meetingId.value}/detail`)
|
||||
meeting.value = res.meeting
|
||||
attendees.value = res.attendees || []
|
||||
agendas.value = res.agendas || []
|
||||
|
||||
// 폼 초기화
|
||||
form.value = {
|
||||
meetingTitle: res.meeting.meetingTitle,
|
||||
meetingType: res.meeting.meetingType,
|
||||
projectId: res.meeting.projectId || '',
|
||||
meetingDate: res.meeting.meetingDate?.split('T')[0] || '',
|
||||
startTime: res.meeting.startTime?.slice(0, 5) || '',
|
||||
endTime: res.meeting.endTime?.slice(0, 5) || '',
|
||||
location: res.meeting.location || '',
|
||||
rawContent: res.meeting.rawContent || '',
|
||||
attendees: res.attendees.map((a: any) => ({
|
||||
employeeId: a.employeeId,
|
||||
employeeName: a.employeeName,
|
||||
company: a.company,
|
||||
externalName: a.externalName,
|
||||
externalCompany: a.externalCompany,
|
||||
isExternal: a.isExternal
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load meeting error:', e)
|
||||
alert('회의록을 불러올 수 없습니다.')
|
||||
router.push('/meeting')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: any[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const res = await $fetch<{ employees: any[] }>('/api/employee/list')
|
||||
employees.value = res.employees || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false
|
||||
// 폼 초기화
|
||||
form.value.attendees = attendees.value.map(a => ({ ...a }))
|
||||
}
|
||||
|
||||
function addSelectedEmployees() {
|
||||
for (const empId of selectedEmployeeIds.value) {
|
||||
if (form.value.attendees.some(a => a.employeeId === empId)) continue
|
||||
const emp = employees.value.find(e => e.employeeId === empId)
|
||||
if (emp) {
|
||||
form.value.attendees.push({
|
||||
employeeId: emp.employeeId,
|
||||
employeeName: emp.employeeName,
|
||||
company: emp.company,
|
||||
isExternal: false
|
||||
})
|
||||
}
|
||||
}
|
||||
selectedEmployeeIds.value = []
|
||||
showEmployeeModal.value = false
|
||||
}
|
||||
|
||||
function addExternalAttendee() {
|
||||
externalForm.value = { name: '', company: '' }
|
||||
showExternalModal.value = true
|
||||
}
|
||||
|
||||
function confirmExternalAttendee() {
|
||||
if (!externalForm.value.name) {
|
||||
alert('이름을 입력하세요.')
|
||||
return
|
||||
}
|
||||
form.value.attendees.push({
|
||||
externalName: externalForm.value.name,
|
||||
externalCompany: externalForm.value.company,
|
||||
isExternal: true
|
||||
})
|
||||
showExternalModal.value = false
|
||||
}
|
||||
|
||||
function removeAttendee(idx: number) {
|
||||
form.value.attendees.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function updateMeeting() {
|
||||
if (!form.value.meetingTitle) {
|
||||
alert('회의 제목을 입력하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch(`/api/meeting/${meetingId.value}/update`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
meetingTitle: form.value.meetingTitle,
|
||||
meetingType: form.value.meetingType,
|
||||
projectId: form.value.projectId || undefined,
|
||||
meetingDate: form.value.meetingDate,
|
||||
startTime: form.value.startTime || undefined,
|
||||
endTime: form.value.endTime || undefined,
|
||||
location: form.value.location || undefined,
|
||||
rawContent: form.value.rawContent || undefined,
|
||||
attendees: form.value.attendees.map(a => ({
|
||||
employeeId: a.employeeId,
|
||||
externalName: a.externalName,
|
||||
externalCompany: a.externalCompany
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
isEditing.value = false
|
||||
await loadMeeting()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '수정에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMeeting() {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return
|
||||
|
||||
try {
|
||||
await $fetch(`/api/meeting/${meetingId.value}/delete`, { method: 'DELETE' })
|
||||
router.push('/meeting')
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
300
frontend/meeting/index.vue
Normal file
300
frontend/meeting/index.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<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-journal-text me-2"></i>회의록
|
||||
</h4>
|
||||
<NuxtLink to="/meeting/write" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>회의록 작성
|
||||
</NuxtLink>
|
||||
</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">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
v-model="filter.keyword"
|
||||
placeholder="제목 또는 내용"
|
||||
@keyup.enter="loadMeetings"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">유형</label></div>
|
||||
<div class="col-2">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="meetingType" id="typeAll" value="" v-model="filter.meetingType" @change="loadMeetings">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="typeAll">전체</label>
|
||||
<input type="radio" class="btn-check" name="meetingType" id="typeProject" value="PROJECT" v-model="filter.meetingType" @change="loadMeetings">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="typeProject">프로젝트</label>
|
||||
<input type="radio" class="btn-check" name="meetingType" id="typeInternal" value="INTERNAL" v-model="filter.meetingType" @change="loadMeetings">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="typeInternal">내부</label>
|
||||
</div>
|
||||
</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.projectId">
|
||||
<option value="">전체</option>
|
||||
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
|
||||
{{ p.projectName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 align-items-center mt-1">
|
||||
<div class="col-1 text-end"><label class="col-form-label">기간</label></div>
|
||||
<div class="col-2">
|
||||
<input type="date" class="form-control form-control-sm" v-model="filter.startDate" />
|
||||
</div>
|
||||
<div class="col-auto px-1">~</div>
|
||||
<div class="col-2">
|
||||
<input type="date" class="form-control form-control-sm" v-model="filter.endDate" />
|
||||
</div>
|
||||
<div class="col-1"></div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-primary btn-sm me-1" @click="loadMeetings">
|
||||
<i class="bi bi-search me-1"></i>조회
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="resetSearch">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 회의록 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>회의록 목록 총 <strong>{{ pagination.total }}</strong>건</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th style="width: 110px">회의일</th>
|
||||
<th>제목</th>
|
||||
<th style="width: 80px" class="text-center">유형</th>
|
||||
<th style="width: 150px">프로젝트</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 me-2"></span>로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="meetings.length === 0">
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">조회된 회의록이 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(meeting, idx) in meetings" :key="meeting.meetingId">
|
||||
<td class="text-center">{{ (pagination.page - 1) * pagination.pageSize + idx + 1 }}</td>
|
||||
<td>
|
||||
{{ formatDate(meeting.meetingDate) }}
|
||||
<br v-if="meeting.startTime" />
|
||||
<small v-if="meeting.startTime" class="text-muted">
|
||||
{{ meeting.startTime?.slice(0, 5) }}
|
||||
<span v-if="meeting.endTime">~{{ meeting.endTime?.slice(0, 5) }}</span>
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink :to="`/meeting/${meeting.meetingId}`" class="text-decoration-none">
|
||||
{{ meeting.meetingTitle }}
|
||||
</NuxtLink>
|
||||
<span v-if="meeting.aiStatus === 'CONFIRMED'" class="badge bg-success ms-1">AI</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span :class="getTypeBadgeClass(meeting.meetingType)">
|
||||
{{ getTypeText(meeting.meetingType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ meeting.projectName || '-' }}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-light text-dark">{{ meeting.attendeeCount }}명</span>
|
||||
</td>
|
||||
<td>{{ meeting.authorName }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
<div class="card-footer d-flex justify-content-between align-items-center" v-if="pagination.totalPages > 1">
|
||||
<small class="text-muted">
|
||||
전체 {{ pagination.total }}건 중 {{ (pagination.page - 1) * pagination.pageSize + 1 }} -
|
||||
{{ Math.min(pagination.page * pagination.pageSize, pagination.total) }}건
|
||||
</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item" :class="{ disabled: pagination.page === 1 }">
|
||||
<a class="page-link" href="#" @click.prevent="goPage(pagination.page - 1)">이전</a>
|
||||
</li>
|
||||
<li
|
||||
class="page-item"
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === pagination.page }"
|
||||
>
|
||||
<a class="page-link" href="#" @click.prevent="goPage(p)">{{ p }}</a>
|
||||
</li>
|
||||
<li class="page-item" :class="{ disabled: pagination.page === pagination.totalPages }">
|
||||
<a class="page-link" href="#" @click.prevent="goPage(pagination.page + 1)">다음</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Meeting {
|
||||
meetingId: number
|
||||
meetingTitle: string
|
||||
meetingDate: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
meetingType: string
|
||||
projectId: number | null
|
||||
projectName: string | null
|
||||
attendeeCount: number
|
||||
authorId: number
|
||||
authorName: string
|
||||
aiStatus: string | null
|
||||
}
|
||||
|
||||
interface Project {
|
||||
projectId: number
|
||||
projectName: string
|
||||
}
|
||||
|
||||
const meetings = ref<Meeting[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const filter = ref({
|
||||
keyword: '',
|
||||
meetingType: '',
|
||||
projectId: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: number[] = []
|
||||
const total = pagination.value.totalPages
|
||||
const current = pagination.value.page
|
||||
|
||||
let start = Math.max(1, current - 2)
|
||||
let end = Math.min(total, current + 2)
|
||||
|
||||
if (end - start < 4) {
|
||||
if (start === 1) end = Math.min(total, 5)
|
||||
else start = Math.max(1, total - 4)
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
return pages
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
await loadProjects()
|
||||
await loadMeetings()
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error('Load projects error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeetings() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>('/api/meeting/list', {
|
||||
query: {
|
||||
keyword: filter.value.keyword || undefined,
|
||||
meetingType: filter.value.meetingType || undefined,
|
||||
projectId: filter.value.projectId || undefined,
|
||||
startDate: filter.value.startDate || undefined,
|
||||
endDate: filter.value.endDate || undefined,
|
||||
page: pagination.value.page,
|
||||
pageSize: pagination.value.pageSize
|
||||
}
|
||||
})
|
||||
meetings.value = res.meetings || []
|
||||
pagination.value.total = res.pagination?.total || 0
|
||||
pagination.value.totalPages = res.pagination?.totalPages || 0
|
||||
} catch (e) {
|
||||
console.error('Load meetings error:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
filter.value = {
|
||||
keyword: '',
|
||||
meetingType: '',
|
||||
projectId: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
}
|
||||
pagination.value.page = 1
|
||||
loadMeetings()
|
||||
}
|
||||
|
||||
function goPage(page: number) {
|
||||
if (page < 1 || page > pagination.value.totalPages) return
|
||||
pagination.value.page = page
|
||||
loadMeetings()
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type: string) {
|
||||
return type === 'PROJECT' ? 'badge bg-primary' : 'badge bg-info'
|
||||
}
|
||||
|
||||
function getTypeText(type: string) {
|
||||
return type === 'PROJECT' ? '프로젝트' : '내부'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
389
frontend/meeting/write.vue
Normal file
389
frontend/meeting/write.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4><i class="bi bi-pencil-square me-2"></i>회의록 작성</h4>
|
||||
<p class="text-muted mb-0">회의 내용을 기록하세요</p>
|
||||
</div>
|
||||
<NuxtLink to="/meeting" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i> 목록으로
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 왼쪽: 기본 정보 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>기본 정보</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 제목 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.meetingTitle" placeholder="회의 제목 입력" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 유형 <span class="text-danger">*</span></label>
|
||||
<div class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" id="type-project" value="PROJECT" v-model="form.meetingType" />
|
||||
<label class="btn btn-outline-primary" for="type-project">프로젝트 회의</label>
|
||||
<input type="radio" class="btn-check" id="type-internal" value="INTERNAL" v-model="form.meetingType" />
|
||||
<label class="btn btn-outline-info" for="type-internal">내부업무 회의</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" v-if="form.meetingType === 'PROJECT'">
|
||||
<label class="form-label">프로젝트 <span class="text-danger">*</span></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="mb-3">
|
||||
<label class="form-label">회의 일자 <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control" v-model="form.meetingDate" />
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">시작 시간</label>
|
||||
<input type="time" class="form-control" v-model="form.startTime" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">종료 시간</label>
|
||||
<input type="time" class="form-control" v-model="form.endTime" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">장소</label>
|
||||
<input type="text" class="form-control" v-model="form.location" placeholder="회의 장소" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 참석자 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>참석자</strong>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @click="showEmployeeModal = true">
|
||||
<i class="bi bi-person-plus"></i> 내부
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="addExternalAttendee">
|
||||
<i class="bi bi-person-plus"></i> 외부
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li
|
||||
v-for="(att, idx) in form.attendees"
|
||||
:key="idx"
|
||||
class="list-group-item d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<div>
|
||||
<span v-if="att.employeeId">
|
||||
<i class="bi bi-person-fill text-primary me-1"></i>
|
||||
{{ att.employeeName }}
|
||||
<small class="text-muted">({{ att.company }})</small>
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="bi bi-person text-secondary me-1"></i>
|
||||
{{ att.externalName }}
|
||||
<small class="text-muted" v-if="att.externalCompany">({{ att.externalCompany }})</small>
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-link text-danger" @click="removeAttendee(idx)">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="form.attendees.length === 0" class="list-group-item text-center text-muted py-4">
|
||||
참석자를 추가하세요
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 회의 내용 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<strong>회의 내용</strong>
|
||||
<small class="text-muted ms-2">(자유롭게 작성하면 AI가 정리해줍니다)</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<!-- TODO: Tiptap 에디터로 교체 -->
|
||||
<textarea
|
||||
class="form-control border-0 h-100"
|
||||
v-model="form.rawContent"
|
||||
placeholder="회의 내용을 자유롭게 작성하세요...
|
||||
|
||||
예시:
|
||||
- 김철수: 이번 주 진행 상황 공유
|
||||
- 프론트엔드 개발 80% 완료
|
||||
- 백엔드 API 연동 필요
|
||||
- 다음 주 목표: 테스트 환경 구축
|
||||
- 미결정: 배포 일정 (추후 논의)"
|
||||
style="min-height: 500px; resize: none;"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<button class="btn btn-secondary me-2" @click="router.push('/meeting')">취소</button>
|
||||
<button class="btn btn-primary" @click="saveMeeting" :disabled="isSaving">
|
||||
<span v-if="isSaving">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>저장 중...
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="bi bi-check-lg me-1"></i>저장
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 직원 선택 모달 -->
|
||||
<div class="modal fade" :class="{ show: showEmployeeModal }" :style="{ display: showEmployeeModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">내부 참석자 추가</h5>
|
||||
<button type="button" class="btn-close" @click="showEmployeeModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control mb-3"
|
||||
v-model="employeeSearch"
|
||||
placeholder="이름 검색..."
|
||||
/>
|
||||
<div style="max-height: 300px; overflow-y: auto;">
|
||||
<div
|
||||
v-for="emp in filteredEmployees"
|
||||
:key="emp.employeeId"
|
||||
class="form-check"
|
||||
>
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:id="`emp-${emp.employeeId}`"
|
||||
:value="emp.employeeId"
|
||||
v-model="selectedEmployeeIds"
|
||||
/>
|
||||
<label class="form-check-label" :for="`emp-${emp.employeeId}`">
|
||||
{{ emp.employeeName }}
|
||||
<small class="text-muted">({{ emp.company }})</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showEmployeeModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="addSelectedEmployees">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showEmployeeModal"></div>
|
||||
|
||||
<!-- 외부 참석자 입력 모달 -->
|
||||
<div class="modal fade" :class="{ show: showExternalModal }" :style="{ display: showExternalModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">외부 참석자 추가</h5>
|
||||
<button type="button" class="btn-close" @click="showExternalModal = 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="externalForm.name" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">소속</label>
|
||||
<input type="text" class="form-control" v-model="externalForm.company" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showExternalModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="confirmExternalAttendee">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showExternalModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Attendee {
|
||||
employeeId?: number
|
||||
employeeName?: string
|
||||
company?: string
|
||||
externalName?: string
|
||||
externalCompany?: string
|
||||
}
|
||||
|
||||
const projects = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
const isSaving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
meetingTitle: '',
|
||||
meetingType: 'PROJECT' as 'PROJECT' | 'INTERNAL',
|
||||
projectId: '' as string | number,
|
||||
meetingDate: new Date().toISOString().split('T')[0],
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
location: '',
|
||||
rawContent: '',
|
||||
attendees: [] as Attendee[]
|
||||
})
|
||||
|
||||
// 직원 선택 모달
|
||||
const showEmployeeModal = ref(false)
|
||||
const employeeSearch = ref('')
|
||||
const selectedEmployeeIds = ref<number[]>([])
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
if (!employeeSearch.value) return employees.value
|
||||
const keyword = employeeSearch.value.toLowerCase()
|
||||
return employees.value.filter(e => e.employeeName.toLowerCase().includes(keyword))
|
||||
})
|
||||
|
||||
// 외부 참석자 모달
|
||||
const showExternalModal = ref(false)
|
||||
const externalForm = ref({ name: '', company: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all([loadProjects(), loadEmployees()])
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: any[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error('Load projects error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const res = await $fetch<{ employees: any[] }>('/api/employee/list')
|
||||
employees.value = res.employees || []
|
||||
} catch (e) {
|
||||
console.error('Load employees error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function addSelectedEmployees() {
|
||||
for (const empId of selectedEmployeeIds.value) {
|
||||
// 이미 추가된 경우 스킵
|
||||
if (form.value.attendees.some(a => a.employeeId === empId)) continue
|
||||
|
||||
const emp = employees.value.find(e => e.employeeId === empId)
|
||||
if (emp) {
|
||||
form.value.attendees.push({
|
||||
employeeId: emp.employeeId,
|
||||
employeeName: emp.employeeName,
|
||||
company: emp.company
|
||||
})
|
||||
}
|
||||
}
|
||||
selectedEmployeeIds.value = []
|
||||
showEmployeeModal.value = false
|
||||
}
|
||||
|
||||
function addExternalAttendee() {
|
||||
externalForm.value = { name: '', company: '' }
|
||||
showExternalModal.value = true
|
||||
}
|
||||
|
||||
function confirmExternalAttendee() {
|
||||
if (!externalForm.value.name) {
|
||||
alert('이름을 입력하세요.')
|
||||
return
|
||||
}
|
||||
form.value.attendees.push({
|
||||
externalName: externalForm.value.name,
|
||||
externalCompany: externalForm.value.company
|
||||
})
|
||||
showExternalModal.value = false
|
||||
}
|
||||
|
||||
function removeAttendee(idx: number) {
|
||||
form.value.attendees.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function saveMeeting() {
|
||||
// 유효성 검사
|
||||
if (!form.value.meetingTitle) {
|
||||
alert('회의 제목을 입력하세요.')
|
||||
return
|
||||
}
|
||||
if (!form.value.meetingDate) {
|
||||
alert('회의 일자를 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (form.value.meetingType === 'PROJECT' && !form.value.projectId) {
|
||||
alert('프로젝트를 선택하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const res = await $fetch<{ success: boolean; meetingId: number }>('/api/meeting/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
meetingTitle: form.value.meetingTitle,
|
||||
meetingType: form.value.meetingType,
|
||||
projectId: form.value.projectId || undefined,
|
||||
meetingDate: form.value.meetingDate,
|
||||
startTime: form.value.startTime || undefined,
|
||||
endTime: form.value.endTime || undefined,
|
||||
location: form.value.location || undefined,
|
||||
rawContent: form.value.rawContent || undefined,
|
||||
attendees: form.value.attendees.map(a => ({
|
||||
employeeId: a.employeeId,
|
||||
externalName: a.externalName,
|
||||
externalCompany: a.externalCompany
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
router.push(`/meeting/${res.meetingId}`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -3,118 +3,197 @@
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="mb-4">
|
||||
<NuxtLink to="/project" class="text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i> 목록으로
|
||||
</NuxtLink>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">
|
||||
<NuxtLink to="/project" class="text-decoration-none text-muted me-2"><i class="bi bi-arrow-left"></i></NuxtLink>
|
||||
<i class="bi bi-folder me-2"></i>{{ project?.projectName || '프로젝트 상세' }}
|
||||
</h4>
|
||||
<div v-if="project">
|
||||
<button class="btn btn-outline-primary me-2" @click="toggleEdit" v-if="!isEditing">
|
||||
<i class="bi bi-pencil me-1"></i>수정
|
||||
</button>
|
||||
<button class="btn btn-secondary me-2" @click="toggleEdit" v-else>취소</button>
|
||||
<button class="btn btn-primary" @click="saveProject" v-if="isEditing" :disabled="isSaving">
|
||||
<span v-if="isSaving"><span class="spinner-border spinner-border-sm me-1"></span></span>
|
||||
<i class="bi bi-check-lg me-1" v-else></i>저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="project">
|
||||
<!-- 프로젝트 기본 정보 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-folder me-2"></i>{{ project.projectName }}
|
||||
</h5>
|
||||
<span :class="getStatusBadgeClass(project.projectStatus)">
|
||||
{{ getStatusText(project.projectStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-muted">프로젝트 코드</label>
|
||||
<p class="mb-0"><code>{{ project.projectCode || '-' }}</code></p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-muted">발주처</label>
|
||||
<p class="mb-0">{{ project.clientName || '-' }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-muted">계약금액</label>
|
||||
<p class="mb-0">{{ formatMoney(project.contractAmount) }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-muted">기간</label>
|
||||
<p class="mb-0">
|
||||
{{ project.startDate ? formatDate(project.startDate) : '-' }} ~
|
||||
{{ project.endDate ? formatDate(project.endDate) : '진행중' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-12" v-if="project.projectDescription">
|
||||
<label class="form-label text-muted">설명</label>
|
||||
<p class="mb-0">{{ project.projectDescription }}</p>
|
||||
</div>
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="project" class="row">
|
||||
<div class="col-md-8">
|
||||
<!-- 프로젝트 기본 정보 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>프로젝트 정보</strong>
|
||||
<span :class="getStatusBadgeClass(project.projectStatus)">{{ getStatusText(project.projectStatus) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PM/PL 이력 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-person-badge me-2"></i>PM/PL 담당 이력</span>
|
||||
<button class="btn btn-sm btn-primary" @click="showAssignModal = true">
|
||||
<i class="bi bi-plus"></i> 담당자 지정
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 80px">역할</th>
|
||||
<th>담당자</th>
|
||||
<th style="width: 200px">기간</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in managers" :key="m.historyId">
|
||||
<td>
|
||||
<span :class="m.roleType === 'PM' ? 'badge bg-primary' : 'badge bg-info'">
|
||||
{{ m.roleType }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ m.employeeName }}</td>
|
||||
<td>
|
||||
{{ formatDate(m.startDate) }} ~
|
||||
{{ m.endDate ? formatDate(m.endDate) : '현재' }}
|
||||
</td>
|
||||
<td>{{ m.changeReason || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="managers.length === 0">
|
||||
<td colspan="4" class="text-center text-muted py-3">
|
||||
담당자 이력이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 참여자 통계 (주간보고 작성 기준) -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-people me-2"></i>참여자 현황
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3" v-for="member in members" :key="member.employeeId">
|
||||
<div class="p-3 border rounded">
|
||||
<strong>{{ member.employeeName }}</strong>
|
||||
<br />
|
||||
<small class="text-muted">{{ member.reportCount }}건 보고</small>
|
||||
<div class="card-body">
|
||||
<!-- 보기 모드 -->
|
||||
<div v-if="!isEditing">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small">프로젝트 코드</label>
|
||||
<p class="mb-0"><code>{{ project.projectCode || '-' }}</code></p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small">유형</label>
|
||||
<p class="mb-0"><span :class="getTypeBadgeClass(project.projectType)">{{ project.projectType || 'SI' }}</span></p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small">상태</label>
|
||||
<p class="mb-0"><span :class="getStatusBadgeClass(project.projectStatus)">{{ getStatusText(project.projectStatus) }}</span></p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small">소속 사업</label>
|
||||
<p class="mb-0">
|
||||
<NuxtLink v-if="project.businessId" :to="`/business/${project.businessId}`" class="text-decoration-none">
|
||||
{{ project.businessName }}
|
||||
</NuxtLink>
|
||||
<span v-else>-</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small">발주처</label>
|
||||
<p class="mb-0">{{ project.clientName || '-' }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small">기간</label>
|
||||
<p class="mb-0">{{ project.startDate ? formatDate(project.startDate) : '-' }} ~ {{ project.endDate ? formatDate(project.endDate) : '진행중' }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small">계약금액</label>
|
||||
<p class="mb-0">{{ formatMoney(project.contractAmount) }}</p>
|
||||
</div>
|
||||
<div class="col-12" v-if="project.projectDescription">
|
||||
<label class="text-muted small">설명</label>
|
||||
<p class="mb-0" style="white-space: pre-wrap;">{{ project.projectDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 text-center text-muted py-3" v-if="members.length === 0">
|
||||
아직 주간보고를 작성한 참여자가 없습니다.
|
||||
<!-- 수정 모드 -->
|
||||
<div v-else>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">프로젝트명</label>
|
||||
<input type="text" class="form-control" v-model="form.projectName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">유형</label>
|
||||
<select class="form-select" v-model="form.projectType">
|
||||
<option value="SI">SI</option>
|
||||
<option value="SM">SM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">소속 사업</label>
|
||||
<select class="form-select" v-model="form.businessId">
|
||||
<option value="">선택 안함</option>
|
||||
<option v-for="b in businesses" :key="b.businessId" :value="b.businessId">{{ b.businessName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">발주처</label>
|
||||
<input type="text" class="form-control" v-model="form.clientName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">시작일</label>
|
||||
<input type="date" class="form-control" v-model="form.startDate" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">종료일</label>
|
||||
<input type="date" class="form-control" v-model="form.endDate" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">상태</label>
|
||||
<select class="form-select" v-model="form.projectStatus">
|
||||
<option value="ACTIVE">진행중</option>
|
||||
<option value="COMPLETED">완료</option>
|
||||
<option value="HOLD">보류</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">계약금액</label>
|
||||
<input type="number" class="form-control" v-model="form.contractAmount" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea class="form-control" v-model="form.projectDescription" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-5" v-else-if="isLoading">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
<!-- PM/PL 이력 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-person-badge me-2"></i>PM/PL 담당 이력</span>
|
||||
<button class="btn btn-sm btn-primary" @click="showAssignModal = true">
|
||||
<i class="bi bi-plus"></i> 담당자 지정
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 80px">역할</th>
|
||||
<th>담당자</th>
|
||||
<th style="width: 200px">기간</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in managers" :key="m.historyId">
|
||||
<td><span :class="m.roleType === 'PM' ? 'badge bg-primary' : 'badge bg-info'">{{ m.roleType }}</span></td>
|
||||
<td>{{ m.employeeName }}</td>
|
||||
<td>{{ formatDate(m.startDate) }} ~ {{ m.endDate ? formatDate(m.endDate) : '현재' }}</td>
|
||||
<td>{{ m.changeReason || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="managers.length === 0">
|
||||
<td colspan="4" class="text-center text-muted py-3">담당자 이력이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<!-- 현재 담당자 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><strong>현재 담당자</strong></div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span>PM</span>
|
||||
<strong>{{ project.currentPm?.employeeName || '-' }}</strong>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span>PL</span>
|
||||
<strong>{{ project.currentPl?.employeeName || '-' }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 등록 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>등록 정보</strong></div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">등록일</span>
|
||||
<span>{{ formatDateTime(project.createdAt) }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">수정일</span>
|
||||
<span>{{ formatDateTime(project.updatedAt) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,9 +217,7 @@
|
||||
<label class="form-label">담당자 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" v-model="assignForm.employeeId">
|
||||
<option value="">선택하세요</option>
|
||||
<option v-for="e in employees" :key="e.employeeId" :value="e.employeeId">
|
||||
{{ e.employeeName }} ({{ e.employeeEmail }})
|
||||
</option>
|
||||
<option v-for="e in employees" :key="e.employeeId" :value="e.employeeId">{{ e.employeeName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -154,9 +231,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showAssignModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="assignManager">
|
||||
<i class="bi bi-check-lg me-1"></i> 지정
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="assignManager"><i class="bi bi-check-lg me-1"></i>지정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,13 +245,29 @@ const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
interface Business { businessId: number; businessName: string }
|
||||
|
||||
const project = ref<any>(null)
|
||||
const businesses = ref<Business[]>([])
|
||||
const managers = ref<any[]>([])
|
||||
const members = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const showAssignModal = ref(false)
|
||||
|
||||
const form = ref({
|
||||
projectName: '',
|
||||
projectType: 'SI',
|
||||
businessId: '',
|
||||
clientName: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
contractAmount: null as number | null,
|
||||
projectStatus: 'ACTIVE',
|
||||
projectDescription: ''
|
||||
})
|
||||
|
||||
const assignForm = ref({
|
||||
roleType: 'PM',
|
||||
employeeId: '',
|
||||
@@ -186,12 +277,8 @@ const assignForm = ref({
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all([loadProject(), loadEmployees()])
|
||||
if (!user) { router.push('/login'); return }
|
||||
await Promise.all([loadProject(), loadBusinesses(), loadEmployees()])
|
||||
})
|
||||
|
||||
async function loadProject() {
|
||||
@@ -199,14 +286,8 @@ async function loadProject() {
|
||||
try {
|
||||
const res = await $fetch<{ project: any }>(`/api/project/${route.params.id}/detail`)
|
||||
project.value = res.project
|
||||
|
||||
// PM/PL 이력 로드
|
||||
const mgRes = await $fetch<{ managers: any[] }>(`/api/project/${route.params.id}/manager-history`)
|
||||
managers.value = mgRes.managers || []
|
||||
|
||||
// 참여자 현황 (주간보고 기준) - 별도 API 필요하면 추가
|
||||
// 임시로 빈 배열
|
||||
members.value = []
|
||||
} catch (e: any) {
|
||||
alert('프로젝트를 불러오는데 실패했습니다.')
|
||||
router.push('/project')
|
||||
@@ -215,12 +296,53 @@ async function loadProject() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBusinesses() {
|
||||
try {
|
||||
const res = await $fetch<{ businesses: Business[] }>('/api/business/list')
|
||||
businesses.value = res.businesses || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const res = await $fetch<{ employees: any[] }>('/api/employee/list')
|
||||
employees.value = res.employees || []
|
||||
} catch (e) {
|
||||
console.error('Load employees error:', e)
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (!isEditing.value) {
|
||||
form.value = {
|
||||
projectName: project.value.projectName || '',
|
||||
projectType: project.value.projectType || 'SI',
|
||||
businessId: project.value.businessId?.toString() || '',
|
||||
clientName: project.value.clientName || '',
|
||||
startDate: project.value.startDate?.split('T')[0] || '',
|
||||
endDate: project.value.endDate?.split('T')[0] || '',
|
||||
contractAmount: project.value.contractAmount,
|
||||
projectStatus: project.value.projectStatus || 'ACTIVE',
|
||||
projectDescription: project.value.projectDescription || ''
|
||||
}
|
||||
}
|
||||
isEditing.value = !isEditing.value
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch(`/api/project/${route.params.id}/update`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...form.value,
|
||||
businessId: form.value.businessId ? Number(form.value.businessId) : null
|
||||
}
|
||||
})
|
||||
isEditing.value = false
|
||||
await loadProject()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,49 +351,32 @@ async function assignManager() {
|
||||
alert('담당자와 시작일은 필수입니다.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch(`/api/project/${route.params.id}/manager-assign`, {
|
||||
method: 'POST',
|
||||
body: assignForm.value
|
||||
})
|
||||
await $fetch(`/api/project/${route.params.id}/manager-assign`, { method: 'POST', body: assignForm.value })
|
||||
showAssignModal.value = false
|
||||
assignForm.value = {
|
||||
roleType: 'PM',
|
||||
employeeId: '',
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
changeReason: ''
|
||||
}
|
||||
assignForm.value = { roleType: 'PM', employeeId: '', startDate: new Date().toISOString().split('T')[0], changeReason: '' }
|
||||
await loadProject()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '지정에 실패했습니다.')
|
||||
alert(e.data?.message || '지정에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type: string) { return type === 'SM' ? 'badge bg-info' : 'badge bg-primary' }
|
||||
function getStatusBadgeClass(status: string) {
|
||||
const classes: Record<string, string> = {
|
||||
'ACTIVE': 'badge bg-success',
|
||||
'COMPLETED': 'badge bg-secondary',
|
||||
'HOLD': 'badge bg-warning'
|
||||
}
|
||||
return classes[status] || 'badge bg-secondary'
|
||||
return { 'ACTIVE': 'badge bg-success', 'COMPLETED': 'badge bg-secondary', 'HOLD': 'badge bg-warning' }[status] || 'badge bg-secondary'
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
const texts: Record<string, string> = {
|
||||
'ACTIVE': '진행중',
|
||||
'COMPLETED': '완료',
|
||||
'HOLD': '보류'
|
||||
}
|
||||
return texts[status] || status
|
||||
return { 'ACTIVE': '진행중', 'COMPLETED': '완료', 'HOLD': '보류' }[status] || status
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
function formatDate(d: string) {
|
||||
if (!d) return ''
|
||||
return new Date(d).toISOString().split('T')[0]
|
||||
}
|
||||
function formatDateTime(d: string) {
|
||||
if (!d) return '-'
|
||||
const dt = new Date(d)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')} ${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function formatMoney(amount: number | null) {
|
||||
if (!amount) return '-'
|
||||
return new Intl.NumberFormat('ko-KR').format(amount) + '원'
|
||||
@@ -279,7 +384,5 @@ function formatMoney(amount: number | null) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.modal.show { background: rgba(0, 0, 0, 0.5); }
|
||||
</style>
|
||||
|
||||
@@ -4,45 +4,49 @@
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4><i class="bi bi-folder me-2"></i>프로젝트 관리</h4>
|
||||
<p class="text-muted mb-0">프로젝트(사업) 정보 관리</p>
|
||||
</div>
|
||||
<h4 class="mb-0"><i class="bi bi-folder me-2"></i>프로젝트 관리</h4>
|
||||
<button class="btn btn-primary" @click="showCreateModal = true">
|
||||
<i class="bi bi-plus-lg me-1"></i> 새 프로젝트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="searchKeyword"
|
||||
placeholder="프로젝트명 또는 코드 검색"
|
||||
/>
|
||||
<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">
|
||||
<input type="text" class="form-control form-control-sm" v-model="searchKeyword" placeholder="프로젝트명/코드" @keyup.enter="loadProjects" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" v-model="filterType">
|
||||
<option value="">전체 유형</option>
|
||||
<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="filterBusinessId" @change="loadProjects">
|
||||
<option value="">전체</option>
|
||||
<option v-for="b in businesses" :key="b.businessId" :value="b.businessId">{{ b.businessName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">유형</label></div>
|
||||
<div class="col-1">
|
||||
<select class="form-select form-select-sm" v-model="filterType" @change="loadProjects">
|
||||
<option value="">전체</option>
|
||||
<option value="SI">SI</option>
|
||||
<option value="SM">SM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" v-model="filterStatus">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="ACTIVE">진행중</option>
|
||||
<option value="COMPLETED">완료</option>
|
||||
<option value="HOLD">보류</option>
|
||||
</select>
|
||||
<div class="col-1 text-end"><label class="col-form-label">상태</label></div>
|
||||
<div class="col-2">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="status" id="statusAll" value="" v-model="filterStatus" @change="loadProjects">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusAll">전체</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusActive" value="ACTIVE" v-model="filterStatus" @change="loadProjects">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusActive">진행</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusCompleted" value="COMPLETED" v-model="filterStatus" @change="loadProjects">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusCompleted">완료</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-outline-secondary" @click="loadProjects">
|
||||
<i class="bi bi-search me-1"></i> 조회
|
||||
<div class="col-1">
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="resetSearch">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,56 +55,56 @@
|
||||
|
||||
<!-- 프로젝트 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
프로젝트 목록 총 <strong>{{ filteredProjects.length }}</strong>건
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<table class="table table-hover table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 120px">코드</th>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th style="width: 100px">코드</th>
|
||||
<th>프로젝트명</th>
|
||||
<th style="width: 80px">유형</th>
|
||||
<th>발주처</th>
|
||||
<th style="width: 120px">기간</th>
|
||||
<th style="width: 100px">상태</th>
|
||||
<th style="width: 80px">상세</th>
|
||||
<th style="width: 120px">사업</th>
|
||||
<th style="width: 60px" class="text-center">유형</th>
|
||||
<th style="width: 120px">발주처</th>
|
||||
<th style="width: 180px">기간</th>
|
||||
<th style="width: 80px" class="text-center">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="project in filteredProjects" :key="project.projectId">
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="8" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="filteredProjects.length === 0">
|
||||
<td colspan="8" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">프로젝트가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(project, idx) in filteredProjects" :key="project.projectId">
|
||||
<td class="text-center">{{ idx + 1 }}</td>
|
||||
<td><code>{{ project.projectCode || '-' }}</code></td>
|
||||
<td>
|
||||
<strong>{{ project.projectName }}</strong>
|
||||
<NuxtLink :to="`/project/${project.projectId}`" class="text-decoration-none">
|
||||
{{ project.projectName }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="getTypeBadgeClass(project.projectType)">
|
||||
{{ project.projectType || 'SI' }}
|
||||
</span>
|
||||
<td>{{ project.businessName || '-' }}</td>
|
||||
<td class="text-center">
|
||||
<span :class="getTypeBadgeClass(project.projectType)">{{ project.projectType || 'SI' }}</span>
|
||||
</td>
|
||||
<td>{{ project.clientName || '-' }}</td>
|
||||
<td>
|
||||
<small v-if="project.startDate">
|
||||
{{ formatDate(project.startDate) }} ~
|
||||
<br />{{ formatDate(project.endDate) || '진행중' }}
|
||||
{{ formatDate(project.startDate) }} ~ {{ formatDate(project.endDate) || '진행중' }}
|
||||
</small>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="getStatusBadgeClass(project.projectStatus)">
|
||||
{{ getStatusText(project.projectStatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
:to="`/project/${project.projectId}`"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
>
|
||||
<i class="bi bi-eye"></i>
|
||||
</NuxtLink>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filteredProjects.length === 0">
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">프로젝트가 없습니다.</p>
|
||||
<td class="text-center">
|
||||
<span :class="getStatusBadgeClass(project.projectStatus)">{{ getStatusText(project.projectStatus) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -130,19 +134,26 @@
|
||||
<option value="SM">SM (시스템 유지보수)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">소속 사업</label>
|
||||
<select class="form-select" v-model="newProject.businessId">
|
||||
<option value="">선택 안함</option>
|
||||
<option v-for="b in businesses" :key="b.businessId" :value="b.businessId">{{ b.businessName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">발주처</label>
|
||||
<input type="text" class="form-control" v-model="newProject.clientName" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">계약금액 (원)</label>
|
||||
<input type="number" class="form-control" v-model="newProject.contractAmount" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">시작일</label>
|
||||
<input type="date" class="form-control" v-model="newProject.startDate" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">종료일</label>
|
||||
<input type="date" class="form-control" v-model="newProject.endDate" />
|
||||
</div>
|
||||
@@ -151,20 +162,12 @@
|
||||
<textarea class="form-control" v-model="newProject.projectDescription" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
프로젝트 코드는 자동 생성됩니다. (예: 2026-001)
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="createProject" :disabled="isCreating">
|
||||
<span v-if="isCreating">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>등록 중...
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="bi bi-check-lg me-1"></i>등록
|
||||
</span>
|
||||
<span v-if="isCreating"><span class="spinner-border spinner-border-sm me-1"></span>등록 중...</span>
|
||||
<span v-else><i class="bi bi-check-lg me-1"></i>등록</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,16 +181,22 @@
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Business { businessId: number; businessName: string }
|
||||
|
||||
const projects = ref<any[]>([])
|
||||
const businesses = ref<Business[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const filterBusinessId = ref('')
|
||||
const filterType = ref('')
|
||||
const filterStatus = ref('')
|
||||
const showCreateModal = ref(false)
|
||||
const isCreating = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const newProject = ref({
|
||||
projectName: '',
|
||||
projectType: 'SI',
|
||||
businessId: '',
|
||||
clientName: '',
|
||||
contractAmount: null as number | null,
|
||||
startDate: '',
|
||||
@@ -197,7 +206,6 @@ const newProject = ref({
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
let list = projects.value
|
||||
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
list = list.filter(p =>
|
||||
@@ -205,15 +213,12 @@ const filteredProjects = computed(() => {
|
||||
p.projectCode?.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterType.value) {
|
||||
list = list.filter(p => p.projectType === filterType.value)
|
||||
}
|
||||
|
||||
if (filterStatus.value) {
|
||||
list = list.filter(p => p.projectStatus === filterStatus.value)
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -223,30 +228,57 @@ onMounted(async () => {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await loadBusinesses()
|
||||
await loadProjects()
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
async function loadBusinesses() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: any[] }>('/api/project/list')
|
||||
const res = await $fetch<{ businesses: Business[] }>('/api/business/list')
|
||||
businesses.value = res.businesses || []
|
||||
} catch (e) {
|
||||
console.error('Load businesses error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ projects: any[] }>('/api/project/list', {
|
||||
query: {
|
||||
businessId: filterBusinessId.value || undefined,
|
||||
status: filterStatus.value || undefined
|
||||
}
|
||||
})
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error('Load projects error:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchKeyword.value = ''
|
||||
filterBusinessId.value = ''
|
||||
filterType.value = ''
|
||||
filterStatus.value = ''
|
||||
loadProjects()
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!newProject.value.projectName) {
|
||||
alert('프로젝트명은 필수입니다.')
|
||||
return
|
||||
}
|
||||
|
||||
isCreating.value = true
|
||||
try {
|
||||
await $fetch('/api/project/create', {
|
||||
method: 'POST',
|
||||
body: newProject.value
|
||||
body: {
|
||||
...newProject.value,
|
||||
businessId: newProject.value.businessId ? Number(newProject.value.businessId) : null
|
||||
}
|
||||
})
|
||||
showCreateModal.value = false
|
||||
resetNewProject()
|
||||
@@ -262,6 +294,7 @@ function resetNewProject() {
|
||||
newProject.value = {
|
||||
projectName: '',
|
||||
projectType: 'SI',
|
||||
businessId: '',
|
||||
clientName: '',
|
||||
contractAmount: null,
|
||||
startDate: '',
|
||||
@@ -278,19 +311,13 @@ function getStatusBadgeClass(status: string) {
|
||||
const classes: Record<string, string> = {
|
||||
'ACTIVE': 'badge bg-success',
|
||||
'COMPLETED': 'badge bg-secondary',
|
||||
'HOLD': 'badge bg-warning',
|
||||
'CANCELLED': 'badge bg-danger'
|
||||
'HOLD': 'badge bg-warning'
|
||||
}
|
||||
return classes[status] || 'badge bg-secondary'
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
const texts: Record<string, string> = {
|
||||
'ACTIVE': '진행중',
|
||||
'COMPLETED': '완료',
|
||||
'HOLD': '보류',
|
||||
'CANCELLED': '취소'
|
||||
}
|
||||
const texts: Record<string, string> = { 'ACTIVE': '진행중', 'COMPLETED': '완료', 'HOLD': '보류' }
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
@@ -302,7 +329,5 @@ function formatDate(dateStr: string) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.modal.show { background: rgba(0, 0, 0, 0.5); }
|
||||
</style>
|
||||
|
||||
813
package-lock.json
generated
813
package-lock.json
generated
@@ -9,6 +9,10 @@
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
"@tiptap/extension-placeholder": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@tiptap/vue-3": "^3.15.3",
|
||||
"nuxt": "^3.15.4",
|
||||
"openai": "^6.15.0",
|
||||
"pg": "^8.13.1",
|
||||
@@ -50,7 +54,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -999,6 +1002,31 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||
@@ -2787,6 +2815,12 @@
|
||||
"integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.53",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
||||
@@ -3309,6 +3343,446 @@
|
||||
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
|
||||
"integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.15.3.tgz",
|
||||
"integrity": "sha512-13x5UsQXtttFpoS/n1q173OeurNxppsdWgP3JfsshzyxIghhC141uL3H6SGYQLPU31AizgDs2OEzt6cSUevaZg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.15.3.tgz",
|
||||
"integrity": "sha512-I8JYbkkUTNUXbHd/wCse2bR0QhQtJD7+0/lgrKOmGfv5ioLxcki079Nzuqqay3PjgYoJLIJQvm3RAGxT+4X91w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.15.3.tgz",
|
||||
"integrity": "sha512-e88DG1bTy6hKxrt7iPVQhJnH5/EOrnKpIyp09dfRDgWrrW88fE0Qjys7a/eT8W+sXyXM3z10Ye7zpERWsrLZDg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.15.3.tgz",
|
||||
"integrity": "sha512-MGwEkNT7ltst6XaWf0ObNgpKQ4PvuuV3igkBrdYnQS+qaAx9IF4isygVPqUc9DvjYC306jpyKsNqNrENIXcosA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.15.3.tgz",
|
||||
"integrity": "sha512-x6LFt3Og6MFINYpsMzrJnz7vaT9Yk1t4oXkbJsJRSavdIWBEBcoRudKZ4sSe/AnsYlRJs8FY2uR76mt9e+7xAQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.15.3.tgz",
|
||||
"integrity": "sha512-q1UB9icNfdJppTqMIUWfoRKkx5SSdWIpwZoL2NeOI5Ah3E20/dQKVttIgLhsE521chyvxCYCRaHD5tMNGKfhyw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.15.3.tgz",
|
||||
"integrity": "sha512-AC72nI2gnogBuETCKbZekn+h6t5FGGcZG2abPGKbz/x9rwpb6qV2hcbAQ30t6M7H6cTOh2/Ut8bEV2MtMB15sw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.15.3.tgz",
|
||||
"integrity": "sha512-jGI5XZpdo8GSYQFj7HY15/oEwC2m2TqZz0/Fln5qIhY32XlZhWrsMuMI6WbUJrTH16es7xO6jmRlDsc6g+vJWg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.15.3.tgz",
|
||||
"integrity": "sha512-+3DVBleKKffadEJEdLYxmYAJOjHjLSqtiSFUE3RABT4V2ka1ODy2NIpyKX0o1SvQ5N1jViYT9Q+yUbNa6zCcDw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.15.3.tgz",
|
||||
"integrity": "sha512-Kaw0sNzP0bQI/xEAMSfIpja6xhsu9WqqAK/puzOIS1RKWO47Wps/tzqdSJ9gfslPIb5uY5mKCfy8UR8Xgiia8w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.15.3.tgz",
|
||||
"integrity": "sha512-8HjxmeRbBiXW+7JKemAJtZtHlmXQ9iji398CPQ0yYde68WbIvUhHXjmbJE5pxFvvQTJ/zJv1aISeEOZN2bKBaw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.15.3.tgz",
|
||||
"integrity": "sha512-G1GG6iN1YXPS+75arDpo+bYRzhr3dNDw99c7D7na3aDawa9Qp7sZ/bVrzFUUcVEce0cD6h83yY7AooBxEc67hA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.15.3.tgz",
|
||||
"integrity": "sha512-FYkN7L6JsfwwNEntmLklCVKvgL0B0N47OXMacRk6kYKQmVQ4Nvc7q/VJLpD9sk4wh4KT1aiCBfhKEBTu5pv1fg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.15.3.tgz",
|
||||
"integrity": "sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.15.3.tgz",
|
||||
"integrity": "sha512-PdDXyBF9Wco9U1x6e+b7tKBWG+kqBDXDmaYXHkFm/gYuQCQafVJ5mdrDdKgkHDWVnJzMWZXBcZjT9r57qtlLWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.15.3.tgz",
|
||||
"integrity": "sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.15.3.tgz",
|
||||
"integrity": "sha512-CCxL5ek1p0lO5e8aqhnPzIySldXRSigBFk2fP9OLgdl5qKFLs2MGc19jFlx5+/kjXnEsdQTFbGY1Sizzt0TVDw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.15.3.tgz",
|
||||
"integrity": "sha512-UxqnTEEAKrL+wFQeSyC9z0mgyUUVRS2WTcVFoLZCE6/Xus9F53S4bl7VKFadjmqI4GpDk5Oe2IOUc72o129jWg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.15.3.tgz",
|
||||
"integrity": "sha512-/8uhw528Iy0c9wF6tHCiIn0ToM0Ml6Ll2c/3iPRnKr4IjXwx2Lr994stUFihb+oqGZwV1J8CPcZJ4Ufpdqi4Dw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.15.3.tgz",
|
||||
"integrity": "sha512-lc0Qu/1AgzcEfS67NJMj5tSHHhH6NtA6uUpvppEKGsvJwgE2wKG1onE4isrVXmcGRdxSMiCtyTDemPNMu6/ozQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-placeholder": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.15.3.tgz",
|
||||
"integrity": "sha512-XcHHnojT186hKIoOgcPBesXk89+caNGVUdMtc171Vcr/5s0dpnr4q5LfE+YRC+S85CpCxCRRnh84Ou+XRtOqrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.15.3.tgz",
|
||||
"integrity": "sha512-Y1P3eGNY7RxQs2BcR6NfLo9VfEOplXXHAqkOM88oowWWOE7dMNeFFZM9H8HNxoQgXJ7H0aWW9B7ZTWM9hWli2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.15.3.tgz",
|
||||
"integrity": "sha512-MhkBz8ZvrqOKtKNp+ZWISKkLUlTrDR7tbKZc2OnNcUTttL9dz0HwT+cg91GGz19fuo7ttDcfsPV6eVmflvGToA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.15.3.tgz",
|
||||
"integrity": "sha512-r/IwcNN0W366jGu4Y0n2MiFq9jGa4aopOwtfWO4d+J0DyeS2m7Go3+KwoUqi0wQTiVU74yfi4DF6eRsMQ9/iHQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.15.3.tgz",
|
||||
"integrity": "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
|
||||
"integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-menu": "^1.2.4",
|
||||
"prosemirror-model": "^1.24.1",
|
||||
"prosemirror-schema-basic": "^1.2.3",
|
||||
"prosemirror-schema-list": "^1.5.0",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-trailing-node": "^3.0.0",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.38.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.15.3.tgz",
|
||||
"integrity": "sha512-ia+eQr9Mt1ln2UO+kK4kFTJOrZK4GhvZXFjpCCYuHtco3rhr2fZAIxEEY4cl/vo5VO5WWyPqxhkFeLcoWmNjSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/extension-blockquote": "^3.15.3",
|
||||
"@tiptap/extension-bold": "^3.15.3",
|
||||
"@tiptap/extension-bullet-list": "^3.15.3",
|
||||
"@tiptap/extension-code": "^3.15.3",
|
||||
"@tiptap/extension-code-block": "^3.15.3",
|
||||
"@tiptap/extension-document": "^3.15.3",
|
||||
"@tiptap/extension-dropcursor": "^3.15.3",
|
||||
"@tiptap/extension-gapcursor": "^3.15.3",
|
||||
"@tiptap/extension-hard-break": "^3.15.3",
|
||||
"@tiptap/extension-heading": "^3.15.3",
|
||||
"@tiptap/extension-horizontal-rule": "^3.15.3",
|
||||
"@tiptap/extension-italic": "^3.15.3",
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
"@tiptap/extension-list": "^3.15.3",
|
||||
"@tiptap/extension-list-item": "^3.15.3",
|
||||
"@tiptap/extension-list-keymap": "^3.15.3",
|
||||
"@tiptap/extension-ordered-list": "^3.15.3",
|
||||
"@tiptap/extension-paragraph": "^3.15.3",
|
||||
"@tiptap/extension-strike": "^3.15.3",
|
||||
"@tiptap/extension-text": "^3.15.3",
|
||||
"@tiptap/extension-underline": "^3.15.3",
|
||||
"@tiptap/extensions": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/vue-3": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.15.3.tgz",
|
||||
"integrity": "sha512-iFmf8oLTtQztY+7O7DxxLp43ZRL5N5lP3wV7/RrZy4aFka524/8Lo04fV18t6aevJLRXlxbWokXbT7Ak2XcXBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.15.3",
|
||||
"@tiptap/extension-floating-menu": "^3.15.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3",
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -3325,13 +3799,34 @@
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3580,7 +4075,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
|
||||
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
@@ -3744,7 +4238,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3939,6 +4432,12 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/ast-kit": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
|
||||
@@ -4146,7 +4645,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4254,7 +4752,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -4320,7 +4817,6 @@
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
@@ -4510,6 +5006,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/croner": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/croner/-/croner-9.1.0.tgz",
|
||||
@@ -6108,6 +6610,21 @@
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/listhen": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz",
|
||||
@@ -6260,12 +6777,47 @@
|
||||
"source-map-js": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
||||
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
@@ -7045,6 +7597,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oxc-minify": {
|
||||
"version": "0.102.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.102.0.tgz",
|
||||
@@ -7302,7 +7860,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
@@ -7435,7 +7992,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -7972,12 +8528,228 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
|
||||
"integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-collab": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
||||
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
|
||||
"integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-markdown": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
|
||||
"integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"prosemirror-model": "^1.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-menu": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
|
||||
"integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crelt": "^1.0.0",
|
||||
"prosemirror-commands": "^1.0.0",
|
||||
"prosemirror-history": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-basic": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-trailing-node": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
||||
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remirror/core-constants": "3.0.0",
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prosemirror-model": "^1.22.1",
|
||||
"prosemirror-state": "^1.4.2",
|
||||
"prosemirror-view": "^1.33.8"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-trailing-node/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.10.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz",
|
||||
"integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.41.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocols": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz",
|
||||
"integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
@@ -8187,7 +8959,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
||||
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -8270,6 +9041,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
@@ -9025,7 +9802,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9034,6 +9810,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.2.tgz",
|
||||
@@ -9471,7 +10253,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -9846,7 +10627,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-sfc": "3.5.26",
|
||||
@@ -9883,7 +10663,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
@@ -9894,6 +10673,12 @@
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
"@tiptap/extension-placeholder": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@tiptap/vue-3": "^3.15.3",
|
||||
"nuxt": "^3.15.4",
|
||||
"openai": "^6.15.0",
|
||||
"pg": "^8.13.1",
|
||||
|
||||
Reference in New Issue
Block a user