diff --git a/web-ui/css/work-analysis.css b/web-ui/css/work-analysis.css index 9eeea8e..b144181 100644 --- a/web-ui/css/work-analysis.css +++ b/web-ui/css/work-analysis.css @@ -1159,6 +1159,29 @@ body { .data-table { min-width: 600px; } + + .error-analysis-table { + font-size: 0.8rem; + } + + .error-analysis-table th, + .error-analysis-table td { + padding: 0.5rem 0.25rem; + } + + .error-analysis-table .job-no { + width: 80px; + min-width: 80px; + font-size: 0.75rem; + } + + .error-analysis-table .work-type-breakdown { + gap: 0.15rem; + } + + .error-analysis-table .work-type-item { + padding: 0.15rem; + } } @media (max-width: 480px) { @@ -1267,6 +1290,200 @@ select:focus { outline-offset: 2px; } +/* ========== 오류 분석 테이블 스타일 ========== */ +.error-analysis-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; + font-size: 0.9rem; + background: var(--white); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.error-analysis-table th { + background: #FF6B6B; /* 오류 분석 전용 빨간색 헤더 */ + color: var(--white); + font-weight: 600; + text-align: center; + padding: 1rem 0.75rem; + border-bottom: 2px solid #E74C3C; + font-size: 0.85rem; + line-height: 1.3; +} + +.error-analysis-table td { + padding: 0.75rem; + border-bottom: 1px solid #F8F9FA; + text-align: center; + vertical-align: middle; +} + +.error-analysis-table .job-no, +.error-analysis-table .project-name { + background: #FFE5E5; /* 연한 빨간색 배경 */ + color: #C0392B; + font-weight: 600; + text-align: center; + width: 120px; + min-width: 120px; + vertical-align: middle; +} + +.error-analysis-table .work-content { + text-align: left; + font-weight: 500; + color: var(--gray-800); +} + +.error-analysis-table .total-hours { + font-weight: 600; + color: var(--gray-900); +} + +.error-analysis-table .detail-hours { + text-align: left; + line-height: 1.4; +} + +.error-analysis-table .regular-hours { + color: #27AE60; + font-weight: 500; +} + +.error-analysis-table .error-hours { + color: #E74C3C; + font-weight: 600; +} + +.error-analysis-table .work-type { + text-align: left; + padding: 0.5rem; +} + +.error-analysis-table .work-type-breakdown { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.error-analysis-table .work-type-item { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.25rem; + background: #F8F9FA; + border-radius: 4px; + margin-bottom: 0.25rem; +} + +/* 정규 작업 스타일 */ +.error-analysis-table .work-type-item.regular { + background: #E8F5E8; + border-left: 3px solid #4CAF50; +} + +.error-analysis-table .work-type-item.regular .work-type-name { + color: #2E7D32; +} + +.error-analysis-table .work-type-item.regular .regular-time { + color: #4CAF50; + font-weight: 600; +} + +.error-analysis-table .work-type-item.regular .work-type-status { + color: #388E3C; + font-size: 0.7rem; + font-weight: 500; +} + +/* 오류 작업 스타일 */ +.error-analysis-table .work-type-item.error { + background: #FFEBEE; + border-left: 3px solid #F44336; +} + +.error-analysis-table .work-type-item.error .work-type-name { + color: #C62828; +} + +.error-analysis-table .work-type-item.error .error-time { + color: #F44336; + font-weight: 600; +} + +.error-analysis-table .work-type-item.error .work-type-status { + color: #D32F2F; + font-size: 0.7rem; + font-weight: 600; +} + +.error-analysis-table .work-type-name { + font-weight: 600; + color: var(--gray-800); + font-size: 0.85rem; +} + +.error-analysis-table .work-type-hours { + font-size: 0.75rem; + color: var(--gray-600); +} + +.error-analysis-table .error-percentage { + font-weight: 600; + font-size: 1rem; +} + +.error-analysis-table .error-percentage.has-error { + color: #E74C3C; + background: #FFE5E5; + border-radius: 4px; + padding: 0.25rem; +} + +/* 프로젝트 그룹 스타일 */ +.error-analysis-table .project-group { + border-top: 1px solid #E0E0E0; +} + +.error-analysis-table .project-group:first-child { + border-top: none; +} + +/* 연차/휴무 오류 스타일 */ +.error-analysis-table .vacation-project { + background: #FFF3E0 !important; /* 연한 오렌지 배경 */ + border-top: 3px solid #FF9800 !important; +} + +.error-analysis-table .vacation-project .job-no, +.error-analysis-table .vacation-project .project-name { + background: #FF9800 !important; /* 오렌지 배경 */ + color: var(--white) !important; +} + +.error-analysis-table .vacation-project td { + background: #FFF3E0 !important; + color: #E65100 !important; +} + +/* 총계 행 스타일 */ +.error-analysis-table .total-row { + background: #FFEBEE !important; + color: #C62828; + border-top: 2px solid #E74C3C; + font-weight: 600; +} + +.error-analysis-table .total-row td { + background: #FFEBEE !important; + color: #C62828 !important; + font-weight: 600; + border-bottom: none; +} + /* 다크 모드 지원 (선택사항) */ @media (prefers-color-scheme: dark) { :root { diff --git a/web-ui/pages/analysis/work-analysis.html b/web-ui/pages/analysis/work-analysis.html index b73a74c..f74457f 100644 --- a/web-ui/pages/analysis/work-analysis.html +++ b/web-ui/pages/analysis/work-analysis.html @@ -7,7 +7,7 @@ - + @@ -296,18 +296,47 @@
-
+

⚠️ - 오류 유형별 분석 + 오류 분석

-
- +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Job No.작업내용
Contents
총 시간
Total Hours
세부시간
Detail Hours
작업 타입
Work Type
오류율
Error Rate
+ 분석을 실행하여 데이터를 확인하세요 +
+
@@ -574,27 +603,317 @@ } } - async function analyzeErrorTypes() { - if (!isAnalysisEnabled) { - alert('먼저 기간을 확정해주세요.'); + // 오류 분석 함수 + async function analyzeErrorAnalysis() { + console.log('⚠️ 오류 분석 시작'); + + if (!confirmedStartDate || !confirmedEndDate) { + alert('기간을 먼저 확정해주세요.'); + return; + } + + try { + // 병렬로 API 호출 + const [recentWorkResponse, errorAnalysisResponse] = await Promise.all([ + apiCall(`/work-analysis/recent-work?start=${confirmedStartDate}&end=${confirmedEndDate}&limit=2000`, 'GET'), + apiCall(`/work-analysis/error-analysis?start=${confirmedStartDate}&end=${confirmedEndDate}`, 'GET') + ]); + + console.log('🔍 최근 작업 API 응답:', recentWorkResponse); + console.log('🔍 오류 분석 API 응답:', errorAnalysisResponse); + + if (recentWorkResponse.success && recentWorkResponse.data) { + renderErrorAnalysisTable(recentWorkResponse.data); + console.log('✅ 오류 분석 완료'); + } else { + throw new Error(recentWorkResponse.message || '오류 분석 실패'); + } + } catch (error) { + console.error('❌ 오류 분석 실패:', error); + alert('오류 분석에 실패했습니다.'); + } + } + + // 오류 분석 테이블 렌더링 + function renderErrorAnalysisTable(recentWorkData) { + console.log('📊 오류 분석 테이블 렌더링 시작'); + console.log('📊 받은 데이터:', recentWorkData); + + const tableBody = document.getElementById('errorAnalysisTableBody'); + const tableFooter = document.getElementById('errorAnalysisTableFooter'); + + console.log('📊 DOM 요소 확인:', { tableBody, tableFooter }); + + // DOM 요소 존재 확인 + if (!tableBody) { + console.error('❌ errorAnalysisTableBody 요소를 찾을 수 없습니다'); return; } - console.log('⚠️ 오류 유형별 분석 시작'); - - try { - const params = new URLSearchParams({ - start: confirmedStartDate, - end: confirmedEndDate - }); - - const response = await apiCall(`/work-analysis/error-analysis?${params}`, 'GET'); - renderErrorAnalysisChart(response.data); - console.log('✅ 오류 유형별 분석 완료'); - } catch (error) { - console.error('❌ 오류 유형별 분석 오류:', error); - alert('오류 유형별 분석에 실패했습니다.'); + if (!recentWorkData || recentWorkData.length === 0) { + tableBody.innerHTML = ` + + + 해당 기간에 오류 데이터가 없습니다 + + + `; + if (tableFooter) { + tableFooter.style.display = 'none'; + } + return; } + + // 작업 형태별 오류 데이터 집계 + const errorData = aggregateErrorData(recentWorkData); + + let tableRows = []; + let grandTotalHours = 0; + let grandTotalRegularHours = 0; + let grandTotalErrorHours = 0; + + // 프로젝트별로 그룹화 + const projectGroups = new Map(); + errorData.forEach(workType => { + const projectKey = workType.isVacation ? 'vacation' : workType.project_id; + if (!projectGroups.has(projectKey)) { + projectGroups.set(projectKey, []); + } + projectGroups.get(projectKey).push(workType); + }); + + // 프로젝트별로 렌더링 + Array.from(projectGroups.entries()).forEach(([projectKey, workTypes]) => { + workTypes.forEach((workType, index) => { + grandTotalHours += workType.totalHours; + grandTotalRegularHours += workType.regularHours; + grandTotalErrorHours += workType.errorHours; + + const rowClass = workType.isVacation ? 'vacation-project' : 'project-group'; + const isFirstWorkType = index === 0; + const rowspan = workTypes.length; + + // 세부시간 구성 + let detailHours = []; + if (workType.regularHours > 0) { + detailHours.push(`정규: ${workType.regularHours}h`); + } + + // 오류 세부사항 추가 + workType.errorDetails.forEach(error => { + detailHours.push(`오류: ${error.type} ${error.hours}h`); + }); + + // 작업 타입 구성 (단순화) + let workTypeDisplay = ''; + if (workType.regularHours > 0) { + workTypeDisplay += ` +
+ 정규시간 +
+ `; + } + + workType.errorDetails.forEach(error => { + workTypeDisplay += ` +
+ 오류: ${error.type} +
+ `; + }); + + tableRows.push(` + + ${isFirstWorkType ? `${workType.isVacation ? '연차/휴무' : (workType.project_name || 'N/A')}` : ''} + ${workType.work_type_name} + ${workType.totalHours}h + + ${detailHours.join('
')} + + +
+ ${workTypeDisplay} +
+ + ${workType.errorRate}% + + `); + }); + }); + + if (tableRows.length === 0) { + tableBody.innerHTML = ` + + + 해당 기간에 작업 데이터가 없습니다 + + + `; + if (tableFooter) { + tableFooter.style.display = 'none'; + } + } else { + tableBody.innerHTML = tableRows.join(''); + + // 총계 업데이트 + const totalErrorRate = grandTotalHours > 0 ? ((grandTotalErrorHours / grandTotalHours) * 100).toFixed(1) : '0.0'; + + // 안전한 DOM 요소 접근 + const totalErrorHoursElement = document.getElementById('totalErrorHours'); + if (totalErrorHoursElement) { + totalErrorHoursElement.textContent = `${grandTotalHours}h`; + } + + if (tableFooter) { + const detailHoursCell = tableFooter.querySelector('.total-row td:nth-child(4)'); + const errorRateCell = tableFooter.querySelector('.total-row td:nth-child(6)'); + + if (detailHoursCell) { + detailHoursCell.innerHTML = ` + 정규: ${grandTotalRegularHours}h
오류: ${grandTotalErrorHours}h
+ `; + } + + if (errorRateCell) { + errorRateCell.innerHTML = `${totalErrorRate}%`; + } + + tableFooter.style.display = 'table-footer-group'; + } + } + } + + // 헬퍼 함수들 + function isWeekendDate(dateString) { + const date = new Date(dateString); + const dayOfWeek = date.getDay(); + return dayOfWeek === 0 || dayOfWeek === 6; // 일요일(0) 또는 토요일(6) + } + + function isVacationProject(projectName) { + if (!projectName) return false; + const vacationKeywords = ['연차', '휴무', '휴가', '병가', '특별휴가']; + return vacationKeywords.some(keyword => projectName.includes(keyword)); + } + + // 작업 형태별 오류 데이터 집계 함수 + function aggregateErrorData(recentWorkData) { + const workTypeMap = new Map(); + let vacationData = null; // 연차/휴무 통합 데이터 + + recentWorkData.forEach(work => { + const isWeekend = isWeekendDate(work.report_date); + const isVacation = isVacationProject(work.project_name); + + // 주말 연차는 완전히 제외 + if (isWeekend && isVacation) { + console.log('🏖️ 주말 연차/휴무 제외:', work.report_date, work.project_name); + return; + } + + if (isVacation) { + // 모든 연차/휴무를 하나로 통합 + if (!vacationData) { + vacationData = { + project_id: 'vacation', + project_name: '연차/휴무', + job_no: null, + work_type_id: 'vacation', + work_type_name: '연차/휴무', + regularHours: 0, + errorHours: 0, + errorDetails: new Map(), + isVacation: true + }; + } + + const hours = parseFloat(work.work_hours) || 0; + if (work.work_status === 'error' || work.error_type_id) { + vacationData.errorHours += hours; + const errorTypeName = work.error_type_name || work.error_description || '설계미스'; + if (!vacationData.errorDetails.has(errorTypeName)) { + vacationData.errorDetails.set(errorTypeName, 0); + } + vacationData.errorDetails.set(errorTypeName, + vacationData.errorDetails.get(errorTypeName) + hours + ); + } else { + vacationData.regularHours += hours; + } + } else { + // 일반 프로젝트 처리 + const workTypeKey = work.work_type_id || 'unknown'; + const projectName = work.project_name || `프로젝트 ${work.project_id}`; + const workTypeName = work.work_type_name || `작업유형 ${workTypeKey}`; + + // 작업 형태별로 집계 (프로젝트별로 구분) + const combinedKey = `${work.project_id || 'unknown'}_${workTypeKey}`; + + if (!workTypeMap.has(combinedKey)) { + workTypeMap.set(combinedKey, { + project_id: work.project_id, + project_name: projectName, + job_no: work.job_no, + work_type_id: workTypeKey, + work_type_name: workTypeName, + regularHours: 0, + errorHours: 0, + errorDetails: new Map(), // 오류 유형별 세분화 + isVacation: false + }); + } + + const workTypeData = workTypeMap.get(combinedKey); + const hours = parseFloat(work.work_hours) || 0; + + if (work.work_status === 'error' || work.error_type_id) { + workTypeData.errorHours += hours; + + // 오류 유형별 세분화 + const errorTypeName = work.error_type_name || work.error_description || '설계미스'; + if (!workTypeData.errorDetails.has(errorTypeName)) { + workTypeData.errorDetails.set(errorTypeName, 0); + } + workTypeData.errorDetails.set(errorTypeName, + workTypeData.errorDetails.get(errorTypeName) + hours + ); + } else { + workTypeData.regularHours += hours; + } + } + }); + + // 결과 배열 생성 + const result = Array.from(workTypeMap.values()); + + // 연차/휴무 데이터가 있으면 추가 + if (vacationData && (vacationData.regularHours > 0 || vacationData.errorHours > 0)) { + result.push(vacationData); + } + + // 최종 데이터 처리 + return result.map(wt => ({ + ...wt, + totalHours: wt.regularHours + wt.errorHours, + errorRate: wt.regularHours + wt.errorHours > 0 ? + ((wt.errorHours / (wt.regularHours + wt.errorHours)) * 100).toFixed(1) : '0.0', + errorDetails: Array.from(wt.errorDetails.entries()).map(([type, hours]) => ({ + type, hours + })) + })).filter(wt => wt.totalHours > 0) // 시간이 있는 것만 표시 + .sort((a, b) => { + // 연차/휴무를 맨 아래로 + if (a.isVacation && !b.isVacation) return 1; + if (!a.isVacation && b.isVacation) return -1; + + // 같은 프로젝트 내에서는 오류 시간 순으로 정렬 + if (a.project_id === b.project_id) { + return b.errorHours - a.errorHours; + } + + // 다른 프로젝트는 프로젝트명 순으로 정렬 + return (a.project_name || '').localeCompare(b.project_name || ''); + }); } // 프로젝트별 작업 분포 테이블 렌더링