- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
511 lines
21 KiB
JavaScript
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는 브라우저 환경에서 제거됨
|