diff --git a/backend/api/business-report/[id]/confirm.put.ts b/backend/api/business-report/[id]/confirm.put.ts new file mode 100644 index 0000000..e9e1672 --- /dev/null +++ b/backend/api/business-report/[id]/confirm.put.ts @@ -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: '보고서가 확정되었습니다.' } +}) diff --git a/backend/api/business-report/[id]/detail.get.ts b/backend/api/business-report/[id]/detail.get.ts new file mode 100644 index 0000000..0d2d08d --- /dev/null +++ b/backend/api/business-report/[id]/detail.get.ts @@ -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 + })) + } +}) diff --git a/backend/api/business-report/[id]/update.put.ts b/backend/api/business-report/[id]/update.put.ts new file mode 100644 index 0000000..45836c5 --- /dev/null +++ b/backend/api/business-report/[id]/update.put.ts @@ -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 } +}) diff --git a/backend/api/business-report/generate.post.ts b/backend/api/business-report/generate.post.ts new file mode 100644 index 0000000..38e4a1a --- /dev/null +++ b/backend/api/business-report/generate.post.ts @@ -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(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 = {} + 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' + } + } +}) diff --git a/backend/api/business-report/list.get.ts b/backend/api/business-report/list.get.ts new file mode 100644 index 0000000..8d6efe9 --- /dev/null +++ b/backend/api/business-report/list.get.ts @@ -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 + })) + } +}) diff --git a/backend/api/business/[id]/delete.delete.ts b/backend/api/business/[id]/delete.delete.ts new file mode 100644 index 0000000..6245a64 --- /dev/null +++ b/backend/api/business/[id]/delete.delete.ts @@ -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: '사업이 삭제(중단) 처리되었습니다.' + } +}) diff --git a/backend/api/business/[id]/detail.get.ts b/backend/api/business/[id]/detail.get.ts new file mode 100644 index 0000000..c05eebd --- /dev/null +++ b/backend/api/business/[id]/detail.get.ts @@ -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 + })) + } +}) diff --git a/backend/api/business/[id]/update.put.ts b/backend/api/business/[id]/update.put.ts new file mode 100644 index 0000000..7929707 --- /dev/null +++ b/backend/api/business/[id]/update.put.ts @@ -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(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: '사업이 수정되었습니다.' + } +}) diff --git a/backend/api/business/create.post.ts b/backend/api/business/create.post.ts new file mode 100644 index 0000000..5d129d0 --- /dev/null +++ b/backend/api/business/create.post.ts @@ -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(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: '사업이 등록되었습니다.' + } +}) diff --git a/backend/api/business/list.get.ts b/backend/api/business/list.get.ts new file mode 100644 index 0000000..a094caa --- /dev/null +++ b/backend/api/business/list.get.ts @@ -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 + })) + } +}) diff --git a/backend/api/maintenance/[id]/delete.delete.ts b/backend/api/maintenance/[id]/delete.delete.ts new file mode 100644 index 0000000..32c78f5 --- /dev/null +++ b/backend/api/maintenance/[id]/delete.delete.ts @@ -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: '유지보수 업무가 삭제되었습니다.' + } +}) diff --git a/backend/api/maintenance/[id]/detail.get.ts b/backend/api/maintenance/[id]/detail.get.ts new file mode 100644 index 0000000..3bc01c9 --- /dev/null +++ b/backend/api/maintenance/[id]/detail.get.ts @@ -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 + } + } +}) diff --git a/backend/api/maintenance/[id]/status.put.ts b/backend/api/maintenance/[id]/status.put.ts new file mode 100644 index 0000000..a06d182 --- /dev/null +++ b/backend/api/maintenance/[id]/status.put.ts @@ -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(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: '상태가 변경되었습니다.' + } +}) diff --git a/backend/api/maintenance/[id]/update.put.ts b/backend/api/maintenance/[id]/update.put.ts new file mode 100644 index 0000000..b7bdcc0 --- /dev/null +++ b/backend/api/maintenance/[id]/update.put.ts @@ -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(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: '유지보수 업무가 수정되었습니다.' + } +}) diff --git a/backend/api/maintenance/bulk-create.post.ts b/backend/api/maintenance/bulk-create.post.ts new file mode 100644 index 0000000..33c2ede --- /dev/null +++ b/backend/api/maintenance/bulk-create.post.ts @@ -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(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 + } +}) diff --git a/backend/api/maintenance/create.post.ts b/backend/api/maintenance/create.post.ts new file mode 100644 index 0000000..353cdcd --- /dev/null +++ b/backend/api/maintenance/create.post.ts @@ -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(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: '유지보수 업무가 등록되었습니다.' + } +}) diff --git a/backend/api/maintenance/list.get.ts b/backend/api/maintenance/list.get.ts new file mode 100644 index 0000000..26de1a4 --- /dev/null +++ b/backend/api/maintenance/list.get.ts @@ -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) + } + } +}) diff --git a/backend/api/maintenance/upload.post.ts b/backend/api/maintenance/upload.post.ts new file mode 100644 index 0000000..c248fb3 --- /dev/null +++ b/backend/api/maintenance/upload.post.ts @@ -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 = {} + 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 + } +}) diff --git a/backend/api/meeting/[id]/delete.delete.ts b/backend/api/meeting/[id]/delete.delete.ts new file mode 100644 index 0000000..1190d62 --- /dev/null +++ b/backend/api/meeting/[id]/delete.delete.ts @@ -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: '회의록이 삭제되었습니다.' + } +}) diff --git a/backend/api/meeting/[id]/detail.get.ts b/backend/api/meeting/[id]/detail.get.ts new file mode 100644 index 0000000..429d20f --- /dev/null +++ b/backend/api/meeting/[id]/detail.get.ts @@ -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 + })) + } +}) diff --git a/backend/api/meeting/[id]/update.put.ts b/backend/api/meeting/[id]/update.put.ts new file mode 100644 index 0000000..60c77ec --- /dev/null +++ b/backend/api/meeting/[id]/update.put.ts @@ -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(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: '회의록이 수정되었습니다.' + } +}) diff --git a/backend/api/meeting/create.post.ts b/backend/api/meeting/create.post.ts new file mode 100644 index 0000000..0af982c --- /dev/null +++ b/backend/api/meeting/create.post.ts @@ -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(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: '회의록이 등록되었습니다.' + } +}) diff --git a/backend/api/meeting/list.get.ts b/backend/api/meeting/list.get.ts new file mode 100644 index 0000000..33bec8d --- /dev/null +++ b/backend/api/meeting/list.get.ts @@ -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) + } + } +}) diff --git a/backend/api/project/[id]/detail.get.ts b/backend/api/project/[id]/detail.get.ts index 5299b95..c8b6c4a 100644 --- a/backend/api/project/[id]/detail.get.ts +++ b/backend/api/project/[id]/detail.get.ts @@ -8,7 +8,10 @@ export default defineEventHandler(async (event) => { const projectId = getRouterParam(event, 'id') const project = await queryOne(` - 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, diff --git a/backend/api/project/[id]/update.put.ts b/backend/api/project/[id]/update.put.ts index 86222cd..845e2e3 100644 --- a/backend/api/project/[id]/update.put.ts +++ b/backend/api/project/[id]/update.put.ts @@ -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 diff --git a/backend/api/project/create.post.ts b/backend/api/project/create.post.ts index 4fe2452..b3c8ef3 100644 --- a/backend/api/project/create.post.ts +++ b/backend/api/project/create.post.ts @@ -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 } } }) diff --git a/backend/api/project/list.get.ts b/backend/api/project/list.get.ts index aedaa2b..fa2cf7f 100644 --- a/backend/api/project/list.get.ts +++ b/backend/api/project/list.get.ts @@ -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 diff --git a/backend/utils/user.ts b/backend/utils/user.ts index 4223164..1385705 100644 --- a/backend/utils/user.ts +++ b/backend/utils/user.ts @@ -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 { + return await getAuthenticatedUserId(event) +} + /** * 현재 로그인한 사용자의 이메일 조회 */ diff --git a/claude_temp/00_마스터_작업계획서.md b/claude_temp/00_마스터_작업계획서.md new file mode 100644 index 0000000..cff40d6 --- /dev/null +++ b/claude_temp/00_마스터_작업계획서.md @@ -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 | 최초 작성 | - | diff --git a/claude_temp/01_회의록_TODO_작업계획서.md b/claude_temp/01_회의록_TODO_작업계획서.md new file mode 100644 index 0000000..2c0ca85 --- /dev/null +++ b/claude_temp/01_회의록_TODO_작업계획서.md @@ -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) diff --git a/claude_temp/02_사업_프로젝트_계층구조_작업계획서.md b/claude_temp/02_사업_프로젝트_계층구조_작업계획서.md new file mode 100644 index 0000000..960a9a5 --- /dev/null +++ b/claude_temp/02_사업_프로젝트_계층구조_작업계획서.md @@ -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. **고객사 공유**: 사업 주간보고 외부 공유 링크 diff --git a/claude_temp/03_유지보수_업무관리_작업계획서.md b/claude_temp/03_유지보수_업무관리_작업계획서.md new file mode 100644 index 0000000..72641b3 --- /dev/null +++ b/claude_temp/03_유지보수_업무관리_작업계획서.md @@ -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. **월간 보고서**: 월별 유지보수 현황 자동 생성 diff --git a/claude_temp/04_Gmail_OAuth_로그인_작업계획서.md b/claude_temp/04_Gmail_OAuth_로그인_작업계획서.md new file mode 100644 index 0000000..2146b2f --- /dev/null +++ b/claude_temp/04_Gmail_OAuth_로그인_작업계획서.md @@ -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=주간보고시스템 +``` + +### 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시간 후 만료 처리 diff --git a/claude_temp/05_Synology_SSO_연동_작업계획서.md b/claude_temp/05_Synology_SSO_연동_작업계획서.md new file mode 100644 index 0000000..65b037f --- /dev/null +++ b/claude_temp/05_Synology_SSO_연동_작업계획서.md @@ -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 + +``` + +### 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 계정 → 자동 사용자 등록 (관리자 승인) diff --git a/claude_temp/06_구글그룹_연동_작업계획서.md b/claude_temp/06_구글그룹_연동_작업계획서.md new file mode 100644 index 0000000..d493854 --- /dev/null +++ b/claude_temp/06_구글그룹_연동_작업계획서.md @@ -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. **공유 알림**: 그룹 공유 시 시스템 알림 diff --git a/claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md b/claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md new file mode 100644 index 0000000..e39842b --- /dev/null +++ b/claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md @@ -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 { + + // 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 이벤트 시 실시간 수집 diff --git a/frontend/business-report/[id].vue b/frontend/business-report/[id].vue new file mode 100644 index 0000000..5587a64 --- /dev/null +++ b/frontend/business-report/[id].vue @@ -0,0 +1,204 @@ + + + diff --git a/frontend/business-report/index.vue b/frontend/business-report/index.vue new file mode 100644 index 0000000..1b84653 --- /dev/null +++ b/frontend/business-report/index.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/frontend/business/[id].vue b/frontend/business/[id].vue new file mode 100644 index 0000000..ad1a8b7 --- /dev/null +++ b/frontend/business/[id].vue @@ -0,0 +1,335 @@ + + + + + diff --git a/frontend/business/index.vue b/frontend/business/index.vue new file mode 100644 index 0000000..41fbb9e --- /dev/null +++ b/frontend/business/index.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/frontend/maintenance/[id].vue b/frontend/maintenance/[id].vue new file mode 100644 index 0000000..697526e --- /dev/null +++ b/frontend/maintenance/[id].vue @@ -0,0 +1,468 @@ + + + diff --git a/frontend/maintenance/index.vue b/frontend/maintenance/index.vue new file mode 100644 index 0000000..9b0295a --- /dev/null +++ b/frontend/maintenance/index.vue @@ -0,0 +1,347 @@ + + + diff --git a/frontend/maintenance/upload.vue b/frontend/maintenance/upload.vue new file mode 100644 index 0000000..d7003e3 --- /dev/null +++ b/frontend/maintenance/upload.vue @@ -0,0 +1,251 @@ + + + diff --git a/frontend/maintenance/write.vue b/frontend/maintenance/write.vue new file mode 100644 index 0000000..312ca48 --- /dev/null +++ b/frontend/maintenance/write.vue @@ -0,0 +1,172 @@ + + + diff --git a/frontend/meeting/[id].vue b/frontend/meeting/[id].vue new file mode 100644 index 0000000..615d8fa --- /dev/null +++ b/frontend/meeting/[id].vue @@ -0,0 +1,437 @@ + + + + + diff --git a/frontend/meeting/index.vue b/frontend/meeting/index.vue new file mode 100644 index 0000000..8b67503 --- /dev/null +++ b/frontend/meeting/index.vue @@ -0,0 +1,300 @@ + + + diff --git a/frontend/meeting/write.vue b/frontend/meeting/write.vue new file mode 100644 index 0000000..251cb55 --- /dev/null +++ b/frontend/meeting/write.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/frontend/project/[id].vue b/frontend/project/[id].vue index b82e8d1..3e52695 100644 --- a/frontend/project/[id].vue +++ b/frontend/project/[id].vue @@ -3,118 +3,197 @@
-
- - 목록으로 - +
+

+ + {{ project?.projectName || '프로젝트 상세' }} +

+
+ + + +
-
- -
-
-
- {{ project.projectName }} -
- - {{ getStatusText(project.projectStatus) }} - -
-
-
-
- -

{{ project.projectCode || '-' }}

-
-
- -

{{ project.clientName || '-' }}

-
-
- -

{{ formatMoney(project.contractAmount) }}

-
-
- -

- {{ project.startDate ? formatDate(project.startDate) : '-' }} ~ - {{ project.endDate ? formatDate(project.endDate) : '진행중' }} -

-
-
- -

{{ project.projectDescription }}

-
+
+
+
+ +
+
+ +
+
+ 프로젝트 정보 + {{ getStatusText(project.projectStatus) }}
-
-
- - -
-
- PM/PL 담당 이력 - -
-
- - - - - - - - - - - - - - - - - - - - -
역할담당자기간비고
- - {{ m.roleType }} - - {{ m.employeeName }} - {{ formatDate(m.startDate) }} ~ - {{ m.endDate ? formatDate(m.endDate) : '현재' }} - {{ m.changeReason || '-' }}
- 담당자 이력이 없습니다. -
-
-
- - -
-
- 참여자 현황 -
-
-
-
-
- {{ member.employeeName }} -
- {{ member.reportCount }}건 보고 +
+ +
+
+
+ +

{{ project.projectCode || '-' }}

+
+
+ +

{{ project.projectType || 'SI' }}

+
+
+ +

{{ getStatusText(project.projectStatus) }}

+
+
+ +

+ + {{ project.businessName }} + + - +

+
+
+ +

{{ project.clientName || '-' }}

+
+
+ +

{{ project.startDate ? formatDate(project.startDate) : '-' }} ~ {{ project.endDate ? formatDate(project.endDate) : '진행중' }}

+
+
+ +

{{ formatMoney(project.contractAmount) }}

+
+
+ +

{{ project.projectDescription }}

+
-
- 아직 주간보고를 작성한 참여자가 없습니다. + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
-
-
-
-
+ +
+
+ PM/PL 담당 이력 + +
+
+ + + + + + + + + + + + + + + + + + + + +
역할담당자기간비고
{{ m.roleType }}{{ m.employeeName }}{{ formatDate(m.startDate) }} ~ {{ m.endDate ? formatDate(m.endDate) : '현재' }}{{ m.changeReason || '-' }}
담당자 이력이 없습니다.
+
+
+
+ +
+ +
+
현재 담당자
+
    +
  • + PM + {{ project.currentPm?.employeeName || '-' }} +
  • +
  • + PL + {{ project.currentPl?.employeeName || '-' }} +
  • +
+
+ + +
+
등록 정보
+
    +
  • + 등록일 + {{ formatDateTime(project.createdAt) }} +
  • +
  • + 수정일 + {{ formatDateTime(project.updatedAt) }} +
  • +
+
+
@@ -138,9 +217,7 @@
@@ -154,9 +231,7 @@
@@ -170,13 +245,29 @@ const { fetchCurrentUser } = useAuth() const router = useRouter() const route = useRoute() +interface Business { businessId: number; businessName: string } + const project = ref(null) +const businesses = ref([]) const managers = ref([]) -const members = ref([]) const employees = ref([]) 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 = { - '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 = { - '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) { diff --git a/frontend/project/index.vue b/frontend/project/index.vue index f3fbff2..211a1cf 100644 --- a/frontend/project/index.vue +++ b/frontend/project/index.vue @@ -4,45 +4,49 @@
-
-

프로젝트 관리

-

프로젝트(사업) 정보 관리

-
+

프로젝트 관리

-
-
-
-
- +
+
+
+
+
+
-
- + + + +
+
+
+
-
- +
+
+
+ + + + + + +
-
-
@@ -51,56 +55,56 @@
+
+ 프로젝트 목록 총 {{ filteredProjects.length }}건 +
- +
- + + - - - - - + + + + + - + + + + + + + + - + - - - - - @@ -130,19 +134,26 @@ +
+ + +
-
+
-
+
-
+
@@ -151,20 +162,12 @@
-
- - 프로젝트 코드는 자동 생성됩니다. (예: 2026-001) -
@@ -178,16 +181,22 @@ const { fetchCurrentUser } = useAuth() const router = useRouter() +interface Business { businessId: number; businessName: string } + const projects = ref([]) +const businesses = ref([]) 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 = { '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 = { - 'ACTIVE': '진행중', - 'COMPLETED': '완료', - 'HOLD': '보류', - 'CANCELLED': '취소' - } + const texts: Record = { 'ACTIVE': '진행중', 'COMPLETED': '완료', 'HOLD': '보류' } return texts[status] || status } @@ -302,7 +329,5 @@ function formatDate(dateStr: string) { diff --git a/package-lock.json b/package-lock.json index e76162d..7b35682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5c6bf69..0123542 100644 --- a/package.json +++ b/package.json @@ -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",
코드No코드 프로젝트명유형발주처기간상태상세사업유형발주처기간상태
+ 로딩 중... +
+ +

프로젝트가 없습니다.

+
{{ idx + 1 }} {{ project.projectCode || '-' }} - {{ project.projectName }} + + {{ project.projectName }} + - - {{ project.projectType || 'SI' }} - + {{ project.businessName || '-' }} + {{ project.projectType || 'SI' }} {{ project.clientName || '-' }} - {{ formatDate(project.startDate) }} ~ -
{{ formatDate(project.endDate) || '진행중' }} + {{ formatDate(project.startDate) }} ~ {{ formatDate(project.endDate) || '진행중' }}
-
- - {{ getStatusText(project.projectStatus) }} - - - - - -
- -

프로젝트가 없습니다.

+
+ {{ getStatusText(project.projectStatus) }}