Files
TK-FB-Project/web-ui/js/work-analysis/table-renderer.js
Hyungi Ahn ed40eec261 fix: 그룹 리더 대시보드 작업 저장/삭제 오류 해결 및 작업 분석 시스템 성능 최적화
🔧 그룹 리더 대시보드 수정사항:
- API 호출 방식 수정 (modern-dashboard.js)
- 서버 API 요구사항에 맞는 데이터 구조 변경
- work_entries 배열 구조로 변경
- work_type_id → task_id 필드명 매핑
- 400 Bad Request 오류 해결

 작업 분석 시스템 성능 최적화:
- 중복 함수 제거 (isWeekend, isVacationProject 통합)
- WorkAnalysisAPI 캐싱 시스템 구현 (5분 만료)
- 네임스페이스 조직화 (utils, ui, analysis, render)
- ErrorHandler 통합 에러 처리 시스템
- 성능 모니터링 및 메모리 누수 방지
- GPU 가속 CSS 애니메이션 추가
- 디바운스/스로틀 함수 적용
- 의미 없는 통계 카드 제거

📊 작업 분석 페이지 개선:
- 프로그레스 바 애니메이션
- 토스트 알림 시스템
- 부드러운 전환 효과
- 반응형 최적화
- 메모리 사용량 모니터링
2025-11-05 10:12:52 +09:00

511 lines
21 KiB
JavaScript

/**
* Work Analysis Table Renderer Module
* 작업 분석 테이블 렌더링을 담당하는 모듈
*/
class WorkAnalysisTableRenderer {
constructor() {
this.dataProcessor = window.WorkAnalysisDataProcessor;
}
// ========== 프로젝트 분포 테이블 ==========
/**
* 프로젝트 분포 테이블 렌더링 (Production Report 스타일)
* @param {Array} projectData - 프로젝트 데이터
* @param {Array} workerData - 작업자 데이터
*/
renderProjectDistributionTable(projectData, workerData) {
console.log('📋 프로젝트별 분포 테이블 렌더링 시작');
const tbody = document.getElementById('projectDistributionTableBody');
const tfoot = document.getElementById('projectDistributionTableFooter');
if (!tbody) {
console.error('❌ projectDistributionTableBody 요소를 찾을 수 없습니다');
return;
}
// 프로젝트 데이터가 없으면 작업자 데이터로 대체
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
console.log('⚠️ 프로젝트 데이터가 없어서 작업자 데이터로 대체합니다.');
this._renderFallbackTable(workerData, tbody, tfoot);
return;
}
let tableRows = [];
let grandTotalHours = 0;
let grandTotalManDays = 0;
let grandTotalLaborCost = 0;
// 공수당 인건비 (350,000원)
const manDayRate = 350000;
// 먼저 전체 시간을 계산 (부하율 계산용)
projectData.projects.forEach(project => {
project.workTypes.forEach(workType => {
grandTotalHours += workType.totalHours;
});
});
// 프로젝트별로 렌더링
projectData.projects.forEach(project => {
const projectName = project.project_name || '알 수 없는 프로젝트';
const jobNo = project.job_no || 'N/A';
const workTypes = project.workTypes || [];
if (workTypes.length === 0) {
// 작업유형이 없는 경우
const projectHours = project.totalHours || 0;
const manDays = Math.round((projectHours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
const loadRate = grandTotalHours > 0 ? ((projectHours / grandTotalHours) * 100).toFixed(2) : '0.00';
grandTotalManDays += manDays;
grandTotalLaborCost += laborCost;
const isVacation = project.project_id === 'vacation';
const displayText = isVacation ? projectName : jobNo;
tableRows.push(`
<tr class="project-group ${isVacation ? 'vacation-project' : ''}">
<td class="project-name">${displayText}</td>
<td class="work-content">데이터 없음</td>
<td class="man-days">${manDays}</td>
<td class="load-rate">${loadRate}%</td>
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
</tr>
`);
} else {
// 작업유형별 렌더링
workTypes.forEach((workType, index) => {
const isFirstWorkType = index === 0;
const rowspan = workTypes.length;
const workTypeHours = workType.totalHours || 0;
const manDays = Math.round((workTypeHours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
const loadRate = grandTotalHours > 0 ? ((workTypeHours / grandTotalHours) * 100).toFixed(2) : '0.00';
grandTotalManDays += manDays;
grandTotalLaborCost += laborCost;
const isVacation = project.project_id === 'vacation';
const displayText = isVacation ? projectName : jobNo;
tableRows.push(`
<tr class="project-group ${isVacation ? 'vacation-project' : ''}">
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${displayText}</td>` : ''}
<td class="work-content">${workType.work_type_name}</td>
<td class="man-days">${manDays}</td>
<td class="load-rate">${loadRate}%</td>
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
</tr>
`);
});
// 프로젝트 소계 행 추가
const projectTotalHours = workTypes.reduce((sum, wt) => sum + (wt.totalHours || 0), 0);
const projectTotalManDays = Math.round((projectTotalHours / 8) * 100) / 100;
const projectTotalLaborCost = projectTotalManDays * manDayRate;
const projectLoadRate = grandTotalHours > 0 ? ((projectTotalHours / grandTotalHours) * 100).toFixed(2) : '0.00';
tableRows.push(`
<tr class="project-subtotal">
<td colspan="2"><strong>${projectName} 소계</strong></td>
<td><strong>${projectTotalManDays}</strong></td>
<td><strong>${projectLoadRate}%</strong></td>
<td><strong>₩${projectTotalLaborCost.toLocaleString()}</strong></td>
</tr>
`);
}
});
// 테이블 업데이트
tbody.innerHTML = tableRows.join('');
// 총계 업데이트
if (tfoot) {
document.getElementById('totalManDays').textContent = grandTotalManDays.toFixed(2);
document.getElementById('totalLaborCost').textContent = `${grandTotalLaborCost.toLocaleString()}`;
tfoot.style.display = 'table-footer-group';
}
console.log('✅ 프로젝트별 분포 테이블 렌더링 완료');
}
/**
* 대체 테이블 렌더링 (작업자 데이터 기반)
*/
_renderFallbackTable(workerData, tbody, tfoot) {
if (!workerData || workerData.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
해당 기간에 데이터가 없습니다
</td>
</tr>
`;
if (tfoot) tfoot.style.display = 'none';
return;
}
const manDayRate = 350000;
let totalManDays = 0;
let totalLaborCost = 0;
const tableRows = workerData.map(worker => {
const hours = worker.totalHours || 0;
const manDays = Math.round((hours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
totalManDays += manDays;
totalLaborCost += laborCost;
return `
<tr class="project-group">
<td class="project-name">작업자 기반</td>
<td class="work-content">${worker.worker_name}</td>
<td class="man-days">${manDays}</td>
<td class="load-rate">-</td>
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
</tr>
`;
});
tbody.innerHTML = tableRows.join('');
// 총계 업데이트
if (tfoot) {
document.getElementById('totalManDays').textContent = totalManDays.toFixed(2);
document.getElementById('totalLaborCost').textContent = `${totalLaborCost.toLocaleString()}`;
tfoot.style.display = 'table-footer-group';
}
}
// ========== 오류 분석 테이블 ==========
/**
* 오류 분석 테이블 렌더링
* @param {Array} recentWorkData - 최근 작업 데이터
*/
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;
}
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 = this.dataProcessor.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';
}
}
console.log('✅ 오류 분석 테이블 렌더링 완료');
}
// ========== 기간별 작업 현황 테이블 ==========
/**
* 기간별 작업 현황 테이블 렌더링
* @param {Array} projectData - 프로젝트 데이터
* @param {Array} workerData - 작업자 데이터
* @param {Array} recentWorkData - 최근 작업 데이터
*/
renderWorkStatusTable(projectData, workerData, recentWorkData) {
console.log('📈 기간별 작업 현황 테이블 렌더링 시작');
const tableContainer = document.querySelector('#work-status-tab .table-container');
if (!tableContainer) {
console.error('❌ 작업 현황 테이블 컨테이너를 찾을 수 없습니다');
return;
}
// 데이터가 없는 경우 처리
if (!workerData || workerData.length === 0) {
tableContainer.innerHTML = `
<div style="text-align: center; padding: 3rem; color: #666;">
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
<div style="font-size: 1.2rem; margin-bottom: 0.5rem;">데이터가 없습니다</div>
<div style="font-size: 0.9rem;">선택한 기간에 작업 데이터가 없습니다.</div>
</div>
`;
return;
}
// 작업자별 데이터 처리
const workerStats = this._processWorkerStats(workerData, recentWorkData);
let tableHTML = `
<table class="work-status-table">
<thead>
<tr>
<th>작업자</th>
<th>분류(프로젝트)</th>
<th>작업내용</th>
<th>투입시간</th>
<th>작업공수</th>
<th>작업일/일평균시간</th>
<th>비고</th>
</tr>
</thead>
<tbody>
`;
let totalHours = 0;
let totalManDays = 0;
workerStats.forEach(worker => {
worker.projects.forEach((project, projectIndex) => {
project.workTypes.forEach((workType, workTypeIndex) => {
const isFirstProject = projectIndex === 0 && workTypeIndex === 0;
const workerRowspan = worker.totalRowspan;
totalHours += workType.hours;
totalManDays += workType.manDays;
tableHTML += `
<tr class="worker-group">
${isFirstProject ? `
<td class="worker-name" rowspan="${workerRowspan}">${worker.name}</td>
` : ''}
<td class="project-name">${project.name}</td>
<td class="work-content">${workType.name}</td>
<td class="work-hours">${workType.hours}h</td>
${isFirstProject ? `
<td class="man-days" rowspan="${workerRowspan}">${worker.totalManDays.toFixed(1)}</td>
<td class="work-days" rowspan="${workerRowspan}">${worker.workDays}일 / ${worker.avgHours.toFixed(1)}h</td>
` : ''}
<td class="remarks">${workType.remarks}</td>
</tr>
`;
});
});
});
tableHTML += `
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3"><strong>총 공수</strong></td>
<td><strong>${totalHours}h</strong></td>
<td><strong>${totalManDays.toFixed(1)}</strong></td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
`;
tableContainer.innerHTML = tableHTML;
console.log('✅ 기간별 작업 현황 테이블 렌더링 완료');
}
/**
* 작업자별 통계 처리 (내부 헬퍼)
*/
_processWorkerStats(workerData, recentWorkData) {
if (!workerData || workerData.length === 0) {
return [];
}
return workerData.map(worker => {
// 해당 작업자의 작업 데이터 필터링
const workerWork = recentWorkData ?
recentWorkData.filter(work => work.worker_id === worker.worker_id) : [];
// 프로젝트별로 그룹화
const projectMap = new Map();
workerWork.forEach(work => {
const projectKey = work.project_id || 'unknown';
if (!projectMap.has(projectKey)) {
projectMap.set(projectKey, {
name: work.project_name || `프로젝트 ${projectKey}`,
workTypes: new Map()
});
}
const project = projectMap.get(projectKey);
const workTypeKey = work.work_type_id || 'unknown';
const workTypeName = work.work_type_name || `작업유형 ${workTypeKey}`;
if (!project.workTypes.has(workTypeKey)) {
project.workTypes.set(workTypeKey, {
name: workTypeName,
hours: 0,
remarks: '정상'
});
}
const workType = project.workTypes.get(workTypeKey);
workType.hours += parseFloat(work.work_hours) || 0;
// 오류가 있으면 비고 업데이트
if (work.work_status === 'error' || work.error_type_id) {
workType.remarks = work.error_type_name || work.error_description || '오류';
}
});
// 프로젝트 배열로 변환
const projects = Array.from(projectMap.values()).map(project => ({
...project,
workTypes: Array.from(project.workTypes.values()).map(wt => ({
...wt,
manDays: Math.round((wt.hours / 8) * 10) / 10
}))
}));
// 전체 행 수 계산
const totalRowspan = projects.reduce((sum, p) => sum + p.workTypes.length, 0);
return {
name: worker.worker_name,
totalHours: worker.totalHours || 0,
totalManDays: (worker.totalHours || 0) / 8,
workDays: worker.workingDays || 0,
avgHours: worker.avgHours || 0,
projects,
totalRowspan: Math.max(totalRowspan, 1)
};
});
}
}
// 전역 인스턴스 생성
window.WorkAnalysisTableRenderer = new WorkAnalysisTableRenderer();
// Export는 브라우저 환경에서 제거됨