feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
455
deploy/tkfb-package/web-ui/js/work-analysis/chart-renderer.js
Normal file
455
deploy/tkfb-package/web-ui/js/work-analysis/chart-renderer.js
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Work Analysis Chart Renderer Module
|
||||
* 작업 분석 차트 렌더링을 담당하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisChartRenderer {
|
||||
constructor() {
|
||||
this.charts = new Map(); // 차트 인스턴스 관리
|
||||
this.dataProcessor = window.WorkAnalysisDataProcessor;
|
||||
this.defaultColors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
|
||||
];
|
||||
}
|
||||
|
||||
// ========== 차트 관리 ==========
|
||||
|
||||
/**
|
||||
* 기존 차트 제거
|
||||
* @param {string} chartId - 차트 ID
|
||||
*/
|
||||
destroyChart(chartId) {
|
||||
if (this.charts.has(chartId)) {
|
||||
this.charts.get(chartId).destroy();
|
||||
this.charts.delete(chartId);
|
||||
console.log('🗑️ 차트 제거:', chartId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 차트 제거
|
||||
*/
|
||||
destroyAllCharts() {
|
||||
this.charts.forEach((chart, id) => {
|
||||
chart.destroy();
|
||||
console.log('🗑️ 차트 제거:', id);
|
||||
});
|
||||
this.charts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 생성 및 등록
|
||||
* @param {string} chartId - 차트 ID
|
||||
* @param {HTMLCanvasElement} canvas - 캔버스 요소
|
||||
* @param {Object} config - 차트 설정
|
||||
* @returns {Chart} 생성된 차트 인스턴스
|
||||
*/
|
||||
createChart(chartId, canvas, config) {
|
||||
// 기존 차트가 있으면 제거
|
||||
this.destroyChart(chartId);
|
||||
|
||||
const chart = new Chart(canvas, config);
|
||||
this.charts.set(chartId, chart);
|
||||
|
||||
console.log('📊 차트 생성:', chartId);
|
||||
return chart;
|
||||
}
|
||||
|
||||
// ========== 시계열 차트 ==========
|
||||
|
||||
/**
|
||||
* 시계열 차트 렌더링 (기간별 작업 현황)
|
||||
* @param {string} startDate - 시작일
|
||||
* @param {string} endDate - 종료일
|
||||
* @param {string} projectId - 프로젝트 ID (선택사항)
|
||||
*/
|
||||
async renderTimeSeriesChart(startDate, endDate, projectId = '') {
|
||||
console.log('📈 시계열 차트 렌더링 시작');
|
||||
|
||||
try {
|
||||
const api = window.WorkAnalysisAPI;
|
||||
const dailyTrendResponse = await api.getDailyTrend(startDate, endDate, projectId);
|
||||
|
||||
if (!dailyTrendResponse.success || !dailyTrendResponse.data) {
|
||||
throw new Error('일별 추이 데이터를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('workStatusChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ workStatusChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processTimeSeriesData(dailyTrendResponse.data);
|
||||
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 2,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업시간 (h)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업자 수 (명)'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '일별 작업 현황'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('workStatus', canvas, config);
|
||||
console.log('✅ 시계열 차트 렌더링 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 시계열 차트 렌더링 실패:', error);
|
||||
this._showChartError('workStatusChart', '시계열 차트를 불러올 수 없습니다');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 스택 바 차트 ==========
|
||||
|
||||
/**
|
||||
* 스택 바 차트 렌더링 (프로젝트별 → 작업유형별)
|
||||
* @param {Array} projectData - 프로젝트 데이터
|
||||
*/
|
||||
renderStackedBarChart(projectData) {
|
||||
console.log('📊 스택 바 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('projectDistributionChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ projectDistributionChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
|
||||
this._showChartError('projectDistributionChart', '프로젝트 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 변환
|
||||
const { labels, datasets } = this._processStackedBarData(projectData.projects);
|
||||
|
||||
const config = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 2,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업시간 (h)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트별 작업유형 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
return `${context[0].label}`;
|
||||
},
|
||||
label: function(context) {
|
||||
const workType = context.dataset.label;
|
||||
const hours = context.parsed.y;
|
||||
const percentage = ((hours / projectData.totalHours) * 100).toFixed(1);
|
||||
return `${workType}: ${hours}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('projectDistribution', canvas, config);
|
||||
console.log('✅ 스택 바 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 스택 바 차트 데이터 처리
|
||||
*/
|
||||
_processStackedBarData(projects) {
|
||||
// 모든 작업유형 수집
|
||||
const allWorkTypes = new Set();
|
||||
projects.forEach(project => {
|
||||
project.workTypes.forEach(wt => {
|
||||
allWorkTypes.add(wt.work_type_name);
|
||||
});
|
||||
});
|
||||
|
||||
const workTypeArray = Array.from(allWorkTypes);
|
||||
const labels = projects.map(p => p.project_name);
|
||||
|
||||
// 작업유형별 데이터셋 생성
|
||||
const datasets = workTypeArray.map((workTypeName, index) => {
|
||||
const data = projects.map(project => {
|
||||
const workType = project.workTypes.find(wt => wt.work_type_name === workTypeName);
|
||||
return workType ? workType.totalHours : 0;
|
||||
});
|
||||
|
||||
return {
|
||||
label: workTypeName,
|
||||
data,
|
||||
backgroundColor: this.defaultColors[index % this.defaultColors.length],
|
||||
borderColor: this.defaultColors[index % this.defaultColors.length],
|
||||
borderWidth: 1
|
||||
};
|
||||
});
|
||||
|
||||
return { labels, datasets };
|
||||
}
|
||||
|
||||
// ========== 도넛 차트 ==========
|
||||
|
||||
/**
|
||||
* 도넛 차트 렌더링 (작업자별 성과)
|
||||
* @param {Array} workerData - 작업자 데이터
|
||||
*/
|
||||
renderWorkerPerformanceChart(workerData) {
|
||||
console.log('👤 작업자별 성과 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('workerPerformanceChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ workerPerformanceChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workerData || workerData.length === 0) {
|
||||
this._showChartError('workerPerformanceChart', '작업자 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processDonutChartData(
|
||||
workerData.map(worker => ({
|
||||
name: worker.worker_name,
|
||||
hours: worker.totalHours
|
||||
}))
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 1,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업자별 작업시간 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'right'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label;
|
||||
const value = context.parsed;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('workerPerformance', canvas, config);
|
||||
console.log('✅ 작업자별 성과 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 오류 분석 차트 ==========
|
||||
|
||||
/**
|
||||
* 오류 분석 차트 렌더링
|
||||
* @param {Array} errorData - 오류 데이터
|
||||
*/
|
||||
renderErrorAnalysisChart(errorData) {
|
||||
console.log('⚠️ 오류 분석 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('errorAnalysisChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ errorAnalysisChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!errorData || errorData.length === 0) {
|
||||
this._showChartError('errorAnalysisChart', '오류 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 오류가 있는 데이터만 필터링
|
||||
const errorItems = errorData.filter(item =>
|
||||
item.error_count > 0 || (item.errorDetails && item.errorDetails.length > 0)
|
||||
);
|
||||
|
||||
if (errorItems.length === 0) {
|
||||
this._showChartError('errorAnalysisChart', '오류가 발생한 항목이 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processDonutChartData(
|
||||
errorItems.map(item => ({
|
||||
name: item.project_name || item.name,
|
||||
hours: item.errorHours || item.error_count
|
||||
}))
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 1,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트별 오류 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label;
|
||||
const value = context.parsed;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('errorAnalysis', canvas, config);
|
||||
console.log('✅ 오류 분석 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
|
||||
/**
|
||||
* 차트 오류 표시
|
||||
* @param {string} canvasId - 캔버스 ID
|
||||
* @param {string} message - 오류 메시지
|
||||
*/
|
||||
_showChartError(canvasId, message) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
|
||||
const container = canvas.parentElement;
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">차트를 표시할 수 없습니다</div>
|
||||
<div style="font-size: 0.9rem;">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 리사이즈
|
||||
*/
|
||||
resizeCharts() {
|
||||
this.charts.forEach((chart, id) => {
|
||||
try {
|
||||
chart.resize();
|
||||
console.log('📏 차트 리사이즈:', id);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 차트 리사이즈 실패:', id, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 상태 확인
|
||||
*/
|
||||
getChartStatus() {
|
||||
const status = {};
|
||||
this.charts.forEach((chart, id) => {
|
||||
status[id] = {
|
||||
type: chart.config.type,
|
||||
datasetCount: chart.data.datasets.length,
|
||||
dataPointCount: chart.data.labels ? chart.data.labels.length : 0
|
||||
};
|
||||
});
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisChartRenderer = new WorkAnalysisChartRenderer();
|
||||
|
||||
// 윈도우 리사이즈 이벤트 리스너
|
||||
window.addEventListener('resize', () => {
|
||||
window.WorkAnalysisChartRenderer.resizeCharts();
|
||||
});
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
Reference in New Issue
Block a user