feat: 오류 분석 시스템 완전 재구조화 및 개선

 주요 기능
- Production Report 스타일 오류 분석 테이블 구현
- 작업 형태 중심의 데이터 집계 및 표시
- 프로젝트별 그룹화 (rowspan 적용)
- 연차/휴무 통합 처리 및 주말 제외

📊 테이블 구조 개선
- Job No. → 프로젝트명 표시
- 작업내용 → 작업 형태 (Base, Vessel, Piping Assembly)
- 총 시간 → 작업 형태별 총 시간
- 세부시간 → 정규/오류 유형별 세분화
- 백분율 → 오류율로 변경

🎨 UI/UX 개선
- 프로젝트별 rowspan 그룹화
- 정규(녹색)/오류(빨간색) 시각적 구분
- 연차/휴무 오렌지 색상 테마
- 프로젝트 그룹 경계선 추가

🔧 데이터 처리 로직
- 작업 형태별 오류 데이터 집계
- 오류 유형별 세분화 (설계미스, 발주미스 등)
- 주말 연차/휴무 자동 제외
- 모든 연차/휴무 하나로 통합

🛡️ 안정성 개선
- DOM 요소 null 체크 및 안전한 접근
- 디버깅 로그 추가
- 에러 핸들링 강화
This commit is contained in:
Hyungi Ahn
2025-11-05 08:39:35 +09:00
parent 26f9a4dea2
commit 052e868599
2 changed files with 558 additions and 22 deletions

View File

@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/work-analysis.css?v=33">
<link rel="stylesheet" href="/css/work-analysis.css?v=41">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js?v=1" defer></script>
<script src="/js/api-config.js?v=1" defer></script>
@@ -296,18 +296,47 @@
<!-- 오류 분석 탭 -->
<div id="error-analysis-tab" class="tab-content">
<div class="chart-container chart-type">
<div class="chart-container table-type">
<div class="chart-header">
<h3 class="chart-title">
<span class="icon">⚠️</span>
오류 유형별 분석
오류 분석
</h3>
<button class="chart-analyze-btn" onclick="analyzeErrorTypes()" disabled>
<button class="chart-analyze-btn" onclick="analyzeErrorAnalysis()" disabled>
<span class="icon">🔍</span>
분석 실행
</button>
</div>
<canvas id="errorAnalysisChart" class="chart-canvas"></canvas>
<div class="table-container">
<table class="error-analysis-table" id="errorAnalysisTable">
<thead>
<tr>
<th>Job No.</th>
<th>작업내용<br><small>Contents</small></th>
<th>총 시간<br><small>Total Hours</small></th>
<th>세부시간<br><small>Detail Hours</small></th>
<th>작업 타입<br><small>Work Type</small></th>
<th>오류율<br><small>Error Rate</small></th>
</tr>
</thead>
<tbody id="errorAnalysisTableBody">
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
분석을 실행하여 데이터를 확인하세요
</td>
</tr>
</tbody>
<tfoot id="errorAnalysisTableFooter" style="display: none;">
<tr class="total-row">
<td colspan="2"><strong>합계<br><small>Total</small></strong></td>
<td><strong id="totalErrorHours">-</strong></td>
<td><strong>-</strong></td>
<td><strong>-</strong></td>
<td><strong id="totalErrorRate">-</strong></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
@@ -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 = `
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
해당 기간에 오류 데이터가 없습니다
</td>
</tr>
`;
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(`<span class="regular-hours">정규: ${workType.regularHours}h</span>`);
}
// 오류 세부사항 추가
workType.errorDetails.forEach(error => {
detailHours.push(`<span class="error-hours">오류: ${error.type} ${error.hours}h</span>`);
});
// 작업 타입 구성 (단순화)
let workTypeDisplay = '';
if (workType.regularHours > 0) {
workTypeDisplay += `
<div class="work-type-item regular">
<span class="work-type-status">정규시간</span>
</div>
`;
}
workType.errorDetails.forEach(error => {
workTypeDisplay += `
<div class="work-type-item error">
<span class="work-type-status">오류: ${error.type}</span>
</div>
`;
});
tableRows.push(`
<tr class="${rowClass}">
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${workType.isVacation ? '연차/휴무' : (workType.project_name || 'N/A')}</td>` : ''}
<td class="work-content">${workType.work_type_name}</td>
<td class="total-hours">${workType.totalHours}h</td>
<td class="detail-hours">
${detailHours.join('<br>')}
</td>
<td class="work-type">
<div class="work-type-breakdown">
${workTypeDisplay}
</div>
</td>
<td class="error-percentage ${workType.errorHours > 0 ? 'has-error' : ''}">${workType.errorRate}%</td>
</tr>
`);
});
});
if (tableRows.length === 0) {
tableBody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
해당 기간에 작업 데이터가 없습니다
</td>
</tr>
`;
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 = `
<strong>정규: ${grandTotalRegularHours}h<br>오류: ${grandTotalErrorHours}h</strong>
`;
}
if (errorRateCell) {
errorRateCell.innerHTML = `<strong>${totalErrorRate}%</strong>`;
}
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 || '');
});
}
// 프로젝트별 작업 분포 테이블 렌더링