feat: 오류 분석 시스템 완전 재구조화 및 개선
✨ 주요 기능 - Production Report 스타일 오류 분석 테이블 구현 - 작업 형태 중심의 데이터 집계 및 표시 - 프로젝트별 그룹화 (rowspan 적용) - 연차/휴무 통합 처리 및 주말 제외 📊 테이블 구조 개선 - Job No. → 프로젝트명 표시 - 작업내용 → 작업 형태 (Base, Vessel, Piping Assembly) - 총 시간 → 작업 형태별 총 시간 - 세부시간 → 정규/오류 유형별 세분화 - 백분율 → 오류율로 변경 🎨 UI/UX 개선 - 프로젝트별 rowspan 그룹화 - 정규(녹색)/오류(빨간색) 시각적 구분 - 연차/휴무 오렌지 색상 테마 - 프로젝트 그룹 경계선 추가 🔧 데이터 처리 로직 - 작업 형태별 오류 데이터 집계 - 오류 유형별 세분화 (설계미스, 발주미스 등) - 주말 연차/휴무 자동 제외 - 모든 연차/휴무 하나로 통합 🛡️ 안정성 개선 - DOM 요소 null 체크 및 안전한 접근 - 디버깅 로그 추가 - 에러 핸들링 강화
This commit is contained in:
@@ -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 || '');
|
||||
});
|
||||
}
|
||||
|
||||
// 프로젝트별 작업 분포 테이블 렌더링
|
||||
|
||||
Reference in New Issue
Block a user