diff --git a/web-ui/css/work-analysis.css b/web-ui/css/work-analysis.css index b144181..1278e60 100644 --- a/web-ui/css/work-analysis.css +++ b/web-ui/css/work-analysis.css @@ -103,6 +103,46 @@ body { min-height: 100vh; } +/* ========== 네비게이션 헤더 ========== */ +.breadcrumb-nav { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-6); + padding: var(--space-4); + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + border: 1px solid var(--gray-200); +} + +.breadcrumb-nav .nav-link { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + color: var(--primary); + text-decoration: none; + border-radius: var(--radius); + font-weight: 500; + transition: all var(--transition); +} + +.breadcrumb-nav .nav-link:hover { + background: var(--primary-bg); + color: var(--primary-dark); +} + +.breadcrumb-nav .separator { + color: var(--gray-400); + font-weight: 400; +} + +.breadcrumb-nav .current-page { + color: var(--gray-700); + font-weight: 600; +} + .page-header { background: var(--white); border-radius: var(--radius-xl); @@ -160,6 +200,100 @@ body { gap: var(--space-2); } +/* 분석 탭 네비게이션 */ +.tab-navigation { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--space-4); + margin-bottom: var(--space-6); + box-shadow: var(--shadow-md); + border: 1px solid var(--gray-200); +} + +.tab-buttons { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-3); +} + +/* 탭 컨텐츠 표시/숨김 */ +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* 결과 그리드 */ +.results-grid { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.stats-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.tab-contents { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--space-6); + box-shadow: var(--shadow-lg); + border: 1px solid var(--gray-200); +} + +/* ========== 통계 카드 ========== */ +.stat-card { + background: var(--white); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-md); + border: 1px solid var(--gray-200); + display: flex; + align-items: center; + gap: var(--space-4); + transition: all var(--transition); +} + +.stat-card:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +.stat-icon { + font-size: 2.5rem; + width: 60px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + background: var(--gradient-primary); + border-radius: var(--radius-lg); + color: var(--white); +} + +.stat-content { + flex: 1; +} + +.stat-label { + font-size: 0.875rem; + color: var(--gray-600); + font-weight: 500; + margin-bottom: var(--space-1); +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + color: var(--gray-900); +} + .tab-button { flex: 1; padding: var(--space-4) var(--space-6); @@ -411,6 +545,61 @@ body { overflow: visible; /* 테이블이 보이도록 */ } +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-6); + padding-bottom: var(--space-4); + border-bottom: 2px solid var(--gray-100); +} + +.chart-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--gray-900); + display: flex; + align-items: center; + gap: var(--space-3); +} + +.chart-title .icon { + font-size: 1.25rem; +} + +.chart-analyze-btn { + padding: var(--space-3) var(--space-6); + background: var(--gradient-primary); + color: var(--white); + border: none; + border-radius: var(--radius-lg); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: all var(--transition); + display: flex; + align-items: center; + gap: var(--space-2); + box-shadow: var(--shadow-sm); +} + +.chart-analyze-btn:hover:not(:disabled) { + background: var(--gradient-primary); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.chart-analyze-btn:disabled { + background: var(--gray-300); + color: var(--gray-500); + cursor: not-allowed; + opacity: 0.6; +} + +.chart-analyze-btn .icon { + font-size: 1rem; +} + /* 차트 컨테이너 타입별 스타일 */ .chart-container.chart-type { height: 450px; /* 차트일 때만 고정 높이 */ @@ -435,7 +624,8 @@ body { border: 1px solid var(--gray-200); } -.work-report-table { +.work-report-table, +.work-status-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; diff --git a/web-ui/js/modern-dashboard.js b/web-ui/js/modern-dashboard.js index 128e544..c024f78 100644 --- a/web-ui/js/modern-dashboard.js +++ b/web-ui/js/modern-dashboard.js @@ -752,10 +752,7 @@ async function processVacation(workerId, vacationType, hours) { created_by: currentUser?.user_id || 1 }; - const response = await window.apiCall(`${window.API}/daily-work-reports`, { - method: 'POST', - body: JSON.stringify(vacationReport) - }); + const response = await window.apiCall('/daily-work-reports', 'POST', vacationReport); showToast(`휴가 처리가 완료되었습니다.`, 'success'); await loadDashboardData(); // 데이터 새로고침 @@ -998,7 +995,7 @@ async function loadModalData() { async function loadModalExistingWork() { try { - const response = await window.apiCall(`${window.API}/daily-work-reports?date=${currentModalWorker.date}&worker_id=${currentModalWorker.id}`); + const response = await window.apiCall(`/daily-work-reports?date=${currentModalWorker.date}&worker_id=${currentModalWorker.id}`); modalExistingWork = Array.isArray(response) ? response : (response.data || []); } catch (error) { console.error('기존 작업 로드 오류:', error); @@ -1009,10 +1006,10 @@ async function loadModalExistingWork() { async function loadModalDropdownData() { try { const [projectsRes, workTypesRes, workStatusRes, errorTypesRes] = await Promise.all([ - window.apiCall(`${window.API}/projects/active/list`), - window.apiCall(`${window.API}/daily-work-reports/work-types`), - window.apiCall(`${window.API}/daily-work-reports/work-status-types`), - window.apiCall(`${window.API}/daily-work-reports/error-types`) + window.apiCall('/projects/active/list'), + window.apiCall('/daily-work-reports/work-types'), + window.apiCall('/daily-work-reports/work-status-types'), + window.apiCall('/daily-work-reports/error-types') ]); modalProjects = Array.isArray(projectsRes) ? projectsRes : (projectsRes.data || []); @@ -1153,18 +1150,20 @@ async function saveModalNewWork() { const workData = { report_date: currentModalWorker.date, worker_id: currentModalWorker.id, - project_id: parseInt(projectId), - work_type_id: parseInt(workTypeId), - work_status_id: parseInt(workStatusId), - error_type_id: workStatusId === '2' ? parseInt(errorTypeId) : null, - work_hours: parseFloat(workHours), - created_by: currentUser?.user_id || 1 + work_entries: [{ + project_id: parseInt(projectId), + task_id: parseInt(workTypeId), // work_type_id를 task_id로 매핑 + work_hours: parseFloat(workHours), + work_status_id: parseInt(workStatusId), + error_type_id: workStatusId === '2' ? parseInt(errorTypeId) : null, + description: '' // 기본 설명 + }] }; - await window.apiCall(`${window.API}/daily-work-reports`, { - method: 'POST', - body: JSON.stringify(workData) - }); + console.log('📤 전송할 작업 데이터:', workData); + console.log('📋 현재 사용자:', currentUser); + + await window.apiCall('/daily-work-reports', 'POST', workData); showToast('작업이 성공적으로 저장되었습니다.', 'success'); @@ -1189,9 +1188,7 @@ async function deleteModalWork(workId) { } try { - await window.apiCall(`${window.API}/daily-work-reports/${workId}`, { - method: 'DELETE' - }); + await window.apiCall(`/daily-work-reports/${workId}`, 'DELETE'); showToast('작업이 성공적으로 삭제되었습니다.', 'success'); diff --git a/web-ui/js/work-analysis/api-client.js b/web-ui/js/work-analysis/api-client.js new file mode 100644 index 0000000..9ede593 --- /dev/null +++ b/web-ui/js/work-analysis/api-client.js @@ -0,0 +1,231 @@ +/** + * Work Analysis API Client Module + * 작업 분석 관련 모든 API 호출을 관리하는 모듈 + */ + +class WorkAnalysisAPIClient { + constructor() { + this.baseURL = window.API_BASE_URL || 'http://localhost:20005/api'; + } + + /** + * 기본 API 호출 메서드 + * @param {string} endpoint - API 엔드포인트 + * @param {string} method - HTTP 메서드 + * @param {Object} data - 요청 데이터 + * @returns {Promise} API 응답 + */ + async apiCall(endpoint, method = 'GET', data = null) { + try { + const config = { + method, + headers: { + 'Content-Type': 'application/json', + } + }; + + if (data && method !== 'GET') { + config.body = JSON.stringify(data); + } + + console.log(`📡 API 호출: ${this.baseURL}${endpoint} (${method})`); + + const response = await fetch(`${this.baseURL}${endpoint}`, config); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || `HTTP ${response.status}`); + } + + console.log(`✅ API 성공: ${this.baseURL}${endpoint}`); + return result; + + } catch (error) { + console.error(`❌ API 실패: ${this.baseURL}${endpoint}`, error); + throw error; + } + } + + /** + * 날짜 범위 파라미터 생성 + * @param {string} startDate - 시작일 + * @param {string} endDate - 종료일 + * @param {Object} additionalParams - 추가 파라미터 + * @returns {URLSearchParams} URL 파라미터 + */ + createDateParams(startDate, endDate, additionalParams = {}) { + const params = new URLSearchParams({ + start: startDate, + end: endDate, + ...additionalParams + }); + return params; + } + + // ========== 기본 통계 API ========== + + /** + * 기본 통계 조회 + */ + async getBasicStats(startDate, endDate, projectId = null) { + console.log('🔍 getBasicStats 호출:', startDate, '~', endDate, projectId ? `(프로젝트: ${projectId})` : ''); + const params = this.createDateParams(startDate, endDate, + projectId ? { project_id: projectId } : {} + ); + console.log('🌐 API 요청 URL:', `/work-analysis/stats?${params}`); + return await this.apiCall(`/work-analysis/stats?${params}`); + } + + /** + * 일별 추이 조회 + */ + async getDailyTrend(startDate, endDate, projectId = null) { + const params = this.createDateParams(startDate, endDate, + projectId ? { project_id: projectId } : {} + ); + return await this.apiCall(`/work-analysis/daily-trend?${params}`); + } + + /** + * 작업자별 통계 조회 + */ + async getWorkerStats(startDate, endDate, projectId = null) { + const params = this.createDateParams(startDate, endDate, + projectId ? { project_id: projectId } : {} + ); + return await this.apiCall(`/work-analysis/worker-stats?${params}`); + } + + /** + * 프로젝트별 통계 조회 + */ + async getProjectStats(startDate, endDate) { + const params = this.createDateParams(startDate, endDate); + return await this.apiCall(`/work-analysis/project-stats?${params}`); + } + + // ========== 상세 분석 API ========== + + /** + * 프로젝트별-작업유형별 분석 + */ + async getProjectWorkTypeAnalysis(startDate, endDate, limit = 2000) { + const params = this.createDateParams(startDate, endDate, { limit }); + return await this.apiCall(`/work-analysis/project-worktype-analysis?${params}`); + } + + /** + * 최근 작업 데이터 조회 + */ + async getRecentWork(startDate, endDate, limit = 2000) { + const params = this.createDateParams(startDate, endDate, { limit }); + return await this.apiCall(`/work-analysis/recent-work?${params}`); + } + + /** + * 오류 분석 데이터 조회 + */ + async getErrorAnalysis(startDate, endDate) { + const params = this.createDateParams(startDate, endDate); + return await this.apiCall(`/work-analysis/error-analysis?${params}`); + } + + // ========== 배치 API 호출 ========== + + /** + * 여러 API를 병렬로 호출 + * @param {Array} apiCalls - API 호출 배열 + * @returns {Promise} 결과 배열 + */ + async batchCall(apiCalls) { + console.log('🔄 배치 API 호출 시작:', apiCalls.length, '개'); + + const promises = apiCalls.map(async ({ name, method, ...args }) => { + try { + const result = await this[method](...args); + return { name, success: true, data: result }; + } catch (error) { + console.warn(`⚠️ ${name} API 오류:`, error); + return { name, success: false, error: error.message, data: null }; + } + }); + + const results = await Promise.all(promises); + console.log('✅ 배치 API 호출 완료'); + + return results.reduce((acc, result) => { + acc[result.name] = result; + return acc; + }, {}); + } + + /** + * 차트 데이터를 위한 배치 호출 + */ + async getChartData(startDate, endDate, projectId = null) { + return await this.batchCall([ + { + name: 'dailyTrend', + method: 'getDailyTrend', + startDate, + endDate, + projectId + }, + { + name: 'workerStats', + method: 'getWorkerStats', + startDate, + endDate, + projectId + }, + { + name: 'projectStats', + method: 'getProjectStats', + startDate, + endDate + }, + { + name: 'errorAnalysis', + method: 'getErrorAnalysis', + startDate, + endDate + } + ]); + } + + /** + * 프로젝트 분포 분석을 위한 배치 호출 + */ + async getProjectDistributionData(startDate, endDate) { + return await this.batchCall([ + { + name: 'projectWorkType', + method: 'getProjectWorkTypeAnalysis', + startDate, + endDate + }, + { + name: 'workerStats', + method: 'getWorkerStats', + startDate, + endDate + }, + { + name: 'recentWork', + method: 'getRecentWork', + startDate, + endDate + } + ]); + } +} + +// 전역 인스턴스 생성 +window.WorkAnalysisAPI = new WorkAnalysisAPIClient(); + +// 하위 호환성을 위한 전역 함수 +window.apiCall = (endpoint, method, data) => { + return window.WorkAnalysisAPI.apiCall(endpoint, method, data); +}; + +// Export는 브라우저 환경에서 제거됨 diff --git a/web-ui/js/work-analysis/chart-renderer.js b/web-ui/js/work-analysis/chart-renderer.js new file mode 100644 index 0000000..f1d2911 --- /dev/null +++ b/web-ui/js/work-analysis/chart-renderer.js @@ -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 = ` +
+
📊
+
차트를 표시할 수 없습니다
+
${message}
+
+ `; + } + } + + /** + * 차트 리사이즈 + */ + 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는 브라우저 환경에서 제거됨 diff --git a/web-ui/js/work-analysis/data-processor.js b/web-ui/js/work-analysis/data-processor.js new file mode 100644 index 0000000..bd0e9c1 --- /dev/null +++ b/web-ui/js/work-analysis/data-processor.js @@ -0,0 +1,355 @@ +/** + * Work Analysis Data Processor Module + * 작업 분석 데이터 가공 및 변환을 담당하는 모듈 + */ + +class WorkAnalysisDataProcessor { + + // ========== 유틸리티 함수 ========== + + /** + * 주말 여부 확인 + * @param {string} dateString - 날짜 문자열 + * @returns {boolean} 주말 여부 + */ + isWeekendDate(dateString) { + const date = new Date(dateString); + const dayOfWeek = date.getDay(); + return dayOfWeek === 0 || dayOfWeek === 6; // 일요일(0) 또는 토요일(6) + } + + /** + * 연차/휴무 프로젝트 여부 확인 + * @param {string} projectName - 프로젝트명 + * @returns {boolean} 연차/휴무 여부 + */ + isVacationProject(projectName) { + if (!projectName) return false; + const vacationKeywords = ['연차', '휴무', '휴가', '병가', '특별휴가']; + return vacationKeywords.some(keyword => projectName.includes(keyword)); + } + + /** + * 날짜 포맷팅 (간단한 형식) + * @param {string} dateString - 날짜 문자열 + * @returns {string} 포맷된 날짜 + */ + formatSimpleDate(dateString) { + if (!dateString) return ''; + return dateString.split('T')[0]; // 시간 부분 제거 + } + + // ========== 프로젝트 분포 데이터 처리 ========== + + /** + * 프로젝트별 데이터 집계 + * @param {Array} recentWorkData - 최근 작업 데이터 + * @returns {Object} 집계된 프로젝트 데이터 + */ + aggregateProjectData(recentWorkData) { + console.log('📊 프로젝트 데이터 집계 시작'); + + if (!recentWorkData || recentWorkData.length === 0) { + return { projects: [], totalHours: 0 }; + } + + const projectMap = new Map(); + let vacationData = null; + + recentWorkData.forEach(work => { + const isWeekend = this.isWeekendDate(work.report_date); + const isVacation = this.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, + totalHours: 0, + workTypes: new Map() + }; + } + this._addWorkToProject(vacationData, work, '연차/휴무'); + } else { + // 일반 프로젝트 처리 + const projectKey = work.project_id || 'unknown'; + + if (!projectMap.has(projectKey)) { + projectMap.set(projectKey, { + project_id: projectKey, + project_name: work.project_name || `프로젝트 ${projectKey}`, + job_no: work.job_no, + totalHours: 0, + workTypes: new Map() + }); + } + + const project = projectMap.get(projectKey); + this._addWorkToProject(project, work); + } + }); + + // 결과 배열 생성 + const projects = Array.from(projectMap.values()); + if (vacationData && vacationData.totalHours > 0) { + projects.push(vacationData); + } + + // 작업유형을 배열로 변환하고 정렬 + projects.forEach(project => { + project.workTypes = Array.from(project.workTypes.values()) + .sort((a, b) => b.totalHours - a.totalHours); + }); + + // 프로젝트를 총 시간 순으로 정렬 (연차/휴무는 맨 아래) + projects.sort((a, b) => { + if (a.project_id === 'vacation') return 1; + if (b.project_id === 'vacation') return -1; + return b.totalHours - a.totalHours; + }); + + const totalHours = projects.reduce((sum, p) => sum + p.totalHours, 0); + + console.log('✅ 프로젝트 데이터 집계 완료:', projects.length, '개 프로젝트'); + return { projects, totalHours }; + } + + /** + * 프로젝트에 작업 데이터 추가 (내부 헬퍼) + */ + _addWorkToProject(project, work, overrideWorkTypeName = null) { + const hours = parseFloat(work.work_hours) || 0; + project.totalHours += hours; + + const workTypeKey = work.work_type_id || 'unknown'; + const workTypeName = overrideWorkTypeName || work.work_type_name || `작업유형 ${workTypeKey}`; + + if (!project.workTypes.has(workTypeKey)) { + project.workTypes.set(workTypeKey, { + work_type_id: workTypeKey, + work_type_name: workTypeName, + totalHours: 0 + }); + } + + project.workTypes.get(workTypeKey).totalHours += hours; + } + + // ========== 오류 분석 데이터 처리 ========== + + /** + * 작업 형태별 오류 데이터 집계 + * @param {Array} recentWorkData - 최근 작업 데이터 + * @returns {Array} 집계된 오류 데이터 + */ + aggregateErrorData(recentWorkData) { + console.log('📊 오류 분석 데이터 집계 시작'); + + if (!recentWorkData || recentWorkData.length === 0) { + return []; + } + + const workTypeMap = new Map(); + let vacationData = null; + + recentWorkData.forEach(work => { + const isWeekend = this.isWeekendDate(work.report_date); + const isVacation = this.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 + }; + } + + this._addWorkToErrorData(vacationData, work); + } else { + // 일반 프로젝트 처리 + const workTypeKey = work.work_type_id || 'unknown'; + const combinedKey = `${work.project_id || 'unknown'}_${workTypeKey}`; + + if (!workTypeMap.has(combinedKey)) { + workTypeMap.set(combinedKey, { + project_id: work.project_id, + project_name: work.project_name || `프로젝트 ${work.project_id}`, + job_no: work.job_no, + work_type_id: workTypeKey, + work_type_name: work.work_type_name || `작업유형 ${workTypeKey}`, + regularHours: 0, + errorHours: 0, + errorDetails: new Map(), + isVacation: false + }); + } + + const workTypeData = workTypeMap.get(combinedKey); + this._addWorkToErrorData(workTypeData, work); + } + }); + + // 결과 배열 생성 + const result = Array.from(workTypeMap.values()); + + // 연차/휴무 데이터가 있으면 추가 + if (vacationData && (vacationData.regularHours > 0 || vacationData.errorHours > 0)) { + result.push(vacationData); + } + + // 최종 데이터 처리 + const processedResult = 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 || ''); + }); + + console.log('✅ 오류 분석 데이터 집계 완료:', processedResult.length, '개 항목'); + return processedResult; + } + + /** + * 작업 데이터를 오류 분석 데이터에 추가 (내부 헬퍼) + */ + _addWorkToErrorData(workTypeData, work) { + 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; + } + } + + // ========== 차트 데이터 처리 ========== + + /** + * 시계열 차트 데이터 변환 + * @param {Array} dailyData - 일별 데이터 + * @returns {Object} 차트 데이터 + */ + processTimeSeriesData(dailyData) { + if (!dailyData || dailyData.length === 0) { + return { labels: [], datasets: [] }; + } + + const labels = dailyData.map(item => this.formatSimpleDate(item.date)); + const hours = dailyData.map(item => item.total_hours || 0); + const workers = dailyData.map(item => item.worker_count || 0); + + return { + labels, + datasets: [ + { + label: '총 작업시간', + data: hours, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4 + }, + { + label: '참여 작업자 수', + data: workers, + borderColor: '#10b981', + backgroundColor: 'rgba(16, 185, 129, 0.1)', + tension: 0.4, + yAxisID: 'y1' + } + ] + }; + } + + /** + * 도넛 차트 데이터 변환 + * @param {Array} projectData - 프로젝트 데이터 + * @returns {Object} 차트 데이터 + */ + processDonutChartData(projectData) { + if (!projectData || projectData.length === 0) { + return { labels: [], datasets: [] }; + } + + const labels = projectData.map(item => item.project_name || item.name); + const data = projectData.map(item => item.total_hours || item.hours || 0); + const colors = this._generateColors(data.length); + + return { + labels, + datasets: [{ + data, + backgroundColor: colors, + borderWidth: 2, + borderColor: '#ffffff' + }] + }; + } + + /** + * 색상 생성 헬퍼 + */ + _generateColors(count) { + const baseColors = [ + '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', + '#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1' + ]; + + const colors = []; + for (let i = 0; i < count; i++) { + colors.push(baseColors[i % baseColors.length]); + } + return colors; + } +} + +// 전역 인스턴스 생성 +window.WorkAnalysisDataProcessor = new WorkAnalysisDataProcessor(); + +// Export는 브라우저 환경에서 제거됨 diff --git a/web-ui/js/work-analysis/main-controller.js b/web-ui/js/work-analysis/main-controller.js new file mode 100644 index 0000000..9dd7074 --- /dev/null +++ b/web-ui/js/work-analysis/main-controller.js @@ -0,0 +1,612 @@ +/** + * Work Analysis Main Controller Module + * 작업 분석 페이지의 메인 컨트롤러 - 모든 모듈을 조율하고 사용자 상호작용을 처리 + */ + +class WorkAnalysisMainController { + constructor() { + this.api = window.WorkAnalysisAPI; + this.state = window.WorkAnalysisState; + this.dataProcessor = window.WorkAnalysisDataProcessor; + this.tableRenderer = window.WorkAnalysisTableRenderer; + this.chartRenderer = window.WorkAnalysisChartRenderer; + + this.init(); + } + + /** + * 초기화 + */ + init() { + console.log('🚀 작업 분석 메인 컨트롤러 초기화'); + + this.setupEventListeners(); + this.setupStateListeners(); + this.initializeUI(); + + console.log('✅ 작업 분석 메인 컨트롤러 초기화 완료'); + } + + /** + * 이벤트 리스너 설정 + */ + setupEventListeners() { + // 기간 확정 버튼 + const confirmButton = document.getElementById('confirmPeriodBtn'); + if (confirmButton) { + confirmButton.addEventListener('click', () => this.handlePeriodConfirm()); + } + + // 분석 모드 탭 + document.querySelectorAll('[data-mode]').forEach(button => { + button.addEventListener('click', (e) => { + const mode = e.target.dataset.mode; + this.handleModeChange(mode); + }); + }); + + // 분석 탭 네비게이션 + document.querySelectorAll('[data-tab]').forEach(button => { + button.addEventListener('click', (e) => { + const tabId = e.target.dataset.tab; + this.handleTabChange(tabId); + }); + }); + + // 개별 분석 실행 버튼들 + this.setupAnalysisButtons(); + + // 날짜 입력 필드 + const startDateInput = document.getElementById('startDate'); + const endDateInput = document.getElementById('endDate'); + + if (startDateInput && endDateInput) { + [startDateInput, endDateInput].forEach(input => { + input.addEventListener('change', () => this.handleDateChange()); + }); + } + } + + /** + * 개별 분석 버튼 설정 + */ + setupAnalysisButtons() { + const buttons = [ + { selector: 'button[onclick*="analyzeWorkStatus"]', handler: () => this.analyzeWorkStatus() }, + { selector: 'button[onclick*="analyzeProjectDistribution"]', handler: () => this.analyzeProjectDistribution() }, + { selector: 'button[onclick*="analyzeWorkerPerformance"]', handler: () => this.analyzeWorkerPerformance() }, + { selector: 'button[onclick*="analyzeErrorAnalysis"]', handler: () => this.analyzeErrorAnalysis() } + ]; + + buttons.forEach(({ selector, handler }) => { + const button = document.querySelector(selector); + if (button) { + // 기존 onclick 제거하고 새 이벤트 리스너 추가 + button.removeAttribute('onclick'); + button.addEventListener('click', handler); + } + }); + } + + /** + * 상태 리스너 설정 + */ + setupStateListeners() { + // 기간 확정 상태 변경 시 UI 업데이트 + this.state.subscribe('periodConfirmed', (newState, prevState) => { + this.updateAnalysisButtons(newState.isAnalysisEnabled); + + if (newState.confirmedPeriod.confirmed && !prevState.confirmedPeriod.confirmed) { + this.showAnalysisTabs(); + } + }); + + // 로딩 상태 변경 시 UI 업데이트 + this.state.subscribe('loadingState', (newState) => { + if (newState.isLoading) { + this.showLoading(newState.loadingMessage); + } else { + this.hideLoading(); + } + }); + + // 탭 변경 시 UI 업데이트 + this.state.subscribe('tabChange', (newState) => { + this.updateActiveTab(newState.currentTab); + }); + + // 에러 발생 시 처리 + this.state.subscribe('errorOccurred', (newState) => { + if (newState.lastError) { + this.handleError(newState.lastError); + } + }); + } + + /** + * UI 초기화 + */ + initializeUI() { + // 기본 날짜 설정 + const currentState = this.state.getState(); + const startDateInput = document.getElementById('startDate'); + const endDateInput = document.getElementById('endDate'); + + if (startDateInput && currentState.confirmedPeriod.start) { + startDateInput.value = currentState.confirmedPeriod.start; + } + + if (endDateInput && currentState.confirmedPeriod.end) { + endDateInput.value = currentState.confirmedPeriod.end; + } + + // 분석 버튼 초기 상태 설정 + this.updateAnalysisButtons(false); + + // 분석 탭 숨김 + this.hideAnalysisTabs(); + } + + // ========== 이벤트 핸들러 ========== + + /** + * 기간 확정 처리 + */ + async handlePeriodConfirm() { + try { + const startDate = document.getElementById('startDate').value; + const endDate = document.getElementById('endDate').value; + + console.log('🔄 기간 확정 처리 시작:', startDate, '~', endDate); + + this.state.confirmPeriod(startDate, endDate); + + this.showToast('기간이 확정되었습니다', 'success'); + + console.log('✅ 기간 확정 완료 - 각 분석 버튼을 눌러서 데이터를 확인하세요'); + + } catch (error) { + console.error('❌ 기간 확정 처리 오류:', error); + this.state.setError(error); + this.showToast(error.message, 'error'); + } + } + + /** + * 분석 모드 변경 처리 + */ + handleModeChange(mode) { + try { + this.state.setAnalysisMode(mode); + this.updateModeButtons(mode); + + // 캐시 초기화 + this.state.clearCache(); + + } catch (error) { + this.state.setError(error); + } + } + + /** + * 탭 변경 처리 + */ + handleTabChange(tabId) { + this.state.setCurrentTab(tabId); + } + + /** + * 날짜 변경 처리 + */ + handleDateChange() { + // 날짜가 변경되면 기간 확정 상태 해제 + this.state.updateState({ + confirmedPeriod: { + ...this.state.getState().confirmedPeriod, + confirmed: false + }, + isAnalysisEnabled: false + }); + + this.updateAnalysisButtons(false); + this.hideAnalysisTabs(); + } + + // ========== 분석 실행 ========== + + /** + * 기본 통계 로드 + */ + async loadBasicStats() { + const currentState = this.state.getState(); + const { start, end } = currentState.confirmedPeriod; + + try { + console.log('📊 기본 통계 로딩 시작 - 기간:', start, '~', end); + this.state.startLoading('기본 통계를 로딩 중입니다...'); + + console.log('🌐 API 호출 전 - getBasicStats 호출...'); + const statsResponse = await this.api.getBasicStats(start, end); + + console.log('📊 기본 통계 API 응답:', statsResponse); + + if (statsResponse.success && statsResponse.data) { + const stats = statsResponse.data; + + // 정상/오류 시간 계산 + const totalHours = stats.totalHours || 0; + const errorReports = stats.errorRate || 0; + const errorHours = Math.round(totalHours * (errorReports / 100)); + const normalHours = totalHours - errorHours; + + const cardData = { + totalHours: totalHours, + normalHours: normalHours, + errorHours: errorHours, + workerCount: stats.activeWorkers || stats.activeworkers || 0, + errorRate: errorReports + }; + + this.state.setCache('basicStats', cardData); + this.updateResultCards(cardData); + + console.log('✅ 기본 통계 로딩 완료:', cardData); + } else { + // 기본값으로 카드 업데이트 + const defaultData = { + totalHours: 0, + normalHours: 0, + errorHours: 0, + workerCount: 0, + errorRate: 0 + }; + this.updateResultCards(defaultData); + console.warn('⚠️ 기본 통계 데이터가 없어서 기본값으로 설정'); + } + + } catch (error) { + console.error('❌ 기본 통계 로드 실패:', error); + // 에러 시에도 기본값으로 카드 업데이트 + const defaultData = { + totalHours: 0, + normalHours: 0, + errorHours: 0, + workerCount: 0, + errorRate: 0 + }; + this.updateResultCards(defaultData); + } finally { + this.state.stopLoading(); + } + } + + /** + * 기간별 작업 현황 분석 + */ + async analyzeWorkStatus() { + if (!this.state.canAnalyze()) { + this.showToast('기간을 먼저 확정해주세요', 'warning'); + return; + } + + const currentState = this.state.getState(); + const { start, end } = currentState.confirmedPeriod; + + try { + this.state.startLoading('기간별 작업 현황을 분석 중입니다...'); + + // 실제 API 호출 + const batchData = await this.api.batchCall([ + { + name: 'projectWorkType', + method: 'getProjectWorkTypeAnalysis', + startDate: start, + endDate: end + }, + { + name: 'workerStats', + method: 'getWorkerStats', + startDate: start, + endDate: end + }, + { + name: 'recentWork', + method: 'getRecentWork', + startDate: start, + endDate: end, + limit: 2000 + } + ]); + + console.log('🔍 기간별 작업 현황 API 응답:', batchData); + + // 데이터 처리 + const recentWorkData = batchData.recentWork?.success ? batchData.recentWork.data.data : []; + const workerData = batchData.workerStats?.success ? batchData.workerStats.data.data : []; + + const projectData = this.dataProcessor.aggregateProjectData(recentWorkData); + + // 테이블 렌더링 + this.tableRenderer.renderWorkStatusTable(projectData, workerData, recentWorkData); + + this.showToast('기간별 작업 현황 분석이 완료되었습니다', 'success'); + + } catch (error) { + console.error('❌ 기간별 작업 현황 분석 오류:', error); + this.state.setError(error); + this.showToast('기간별 작업 현황 분석에 실패했습니다', 'error'); + } finally { + this.state.stopLoading(); + } + } + + /** + * 프로젝트별 분포 분석 + */ + async analyzeProjectDistribution() { + if (!this.state.canAnalyze()) { + this.showToast('기간을 먼저 확정해주세요', 'warning'); + return; + } + + const currentState = this.state.getState(); + const { start, end } = currentState.confirmedPeriod; + + try { + this.state.startLoading('프로젝트별 분포를 분석 중입니다...'); + + // 실제 API 호출 + const distributionData = await this.api.getProjectDistributionData(start, end); + + console.log('🔍 프로젝트별 분포 API 응답:', distributionData); + + // 데이터 처리 + const recentWorkData = distributionData.recentWork?.success ? distributionData.recentWork.data.data : []; + const workerData = distributionData.workerStats?.success ? distributionData.workerStats.data.data : []; + + const projectData = this.dataProcessor.aggregateProjectData(recentWorkData); + + console.log('📊 취합된 프로젝트 데이터:', projectData); + + // 테이블 렌더링 + this.tableRenderer.renderProjectDistributionTable(projectData, workerData); + + this.showToast('프로젝트별 분포 분석이 완료되었습니다', 'success'); + + } catch (error) { + console.error('❌ 프로젝트별 분포 분석 오류:', error); + this.state.setError(error); + this.showToast('프로젝트별 분포 분석에 실패했습니다', 'error'); + } finally { + this.state.stopLoading(); + } + } + + /** + * 작업자별 성과 분석 + */ + async analyzeWorkerPerformance() { + if (!this.state.canAnalyze()) { + this.showToast('기간을 먼저 확정해주세요', 'warning'); + return; + } + + const currentState = this.state.getState(); + const { start, end } = currentState.confirmedPeriod; + + try { + this.state.startLoading('작업자별 성과를 분석 중입니다...'); + + const workerStatsResponse = await this.api.getWorkerStats(start, end); + + console.log('👤 작업자 통계 API 응답:', workerStatsResponse); + + if (workerStatsResponse.success && workerStatsResponse.data) { + this.chartRenderer.renderWorkerPerformanceChart(workerStatsResponse.data); + this.showToast('작업자별 성과 분석이 완료되었습니다', 'success'); + } else { + throw new Error('작업자 데이터를 가져올 수 없습니다'); + } + + } catch (error) { + console.error('❌ 작업자별 성과 분석 오류:', error); + this.state.setError(error); + this.showToast('작업자별 성과 분석에 실패했습니다', 'error'); + } finally { + this.state.stopLoading(); + } + } + + /** + * 오류 분석 + */ + async analyzeErrorAnalysis() { + if (!this.state.canAnalyze()) { + this.showToast('기간을 먼저 확정해주세요', 'warning'); + return; + } + + const currentState = this.state.getState(); + const { start, end } = currentState.confirmedPeriod; + + try { + this.state.startLoading('오류 분석을 진행 중입니다...'); + + // 병렬로 API 호출 + const [recentWorkResponse, errorAnalysisResponse] = await Promise.all([ + this.api.getRecentWork(start, end, 2000), + this.api.getErrorAnalysis(start, end) + ]); + + console.log('🔍 오류 분석 API 응답:', recentWorkResponse); + + if (recentWorkResponse.success && recentWorkResponse.data) { + this.tableRenderer.renderErrorAnalysisTable(recentWorkResponse.data); + this.showToast('오류 분석이 완료되었습니다', 'success'); + } else { + throw new Error('작업 데이터를 가져올 수 없습니다'); + } + + } catch (error) { + console.error('❌ 오류 분석 실패:', error); + this.state.setError(error); + this.showToast('오류 분석에 실패했습니다', 'error'); + } finally { + this.state.stopLoading(); + } + } + + // ========== UI 업데이트 ========== + + /** + * 결과 카드 업데이트 + */ + updateResultCards(stats) { + const cards = { + totalHours: stats.totalHours || 0, + normalHours: stats.normalHours || 0, + errorHours: stats.errorHours || 0, + workerCount: stats.activeWorkers || 0, + errorRate: stats.errorRate || 0 + }; + + Object.entries(cards).forEach(([key, value]) => { + const element = document.getElementById(key); + if (element) { + element.textContent = typeof value === 'number' ? + (key.includes('Rate') ? `${value}%` : value.toLocaleString()) : value; + } + }); + } + + /** + * 분석 버튼 상태 업데이트 + */ + updateAnalysisButtons(enabled) { + const buttons = document.querySelectorAll('.chart-analyze-btn'); + buttons.forEach(button => { + button.disabled = !enabled; + button.style.opacity = enabled ? '1' : '0.5'; + }); + } + + /** + * 분석 탭 표시 + */ + showAnalysisTabs() { + const tabNavigation = document.getElementById('analysisTabNavigation'); + if (tabNavigation) { + tabNavigation.style.display = 'block'; + } + } + + /** + * 분석 탭 숨김 + */ + hideAnalysisTabs() { + const tabNavigation = document.getElementById('analysisTabNavigation'); + if (tabNavigation) { + tabNavigation.style.display = 'none'; + } + } + + /** + * 활성 탭 업데이트 + */ + updateActiveTab(tabId) { + // 탭 버튼 업데이트 + document.querySelectorAll('.tab-button').forEach(button => { + button.classList.remove('active'); + if (button.dataset.tab === tabId) { + button.classList.add('active'); + } + }); + + // 탭 컨텐츠 업데이트 + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.remove('active'); + if (content.id === `${tabId}-tab`) { + content.classList.add('active'); + } + }); + } + + /** + * 모드 버튼 업데이트 + */ + updateModeButtons(mode) { + document.querySelectorAll('[data-mode]').forEach(button => { + button.classList.remove('active'); + if (button.dataset.mode === mode) { + button.classList.add('active'); + } + }); + } + + /** + * 로딩 표시 + */ + showLoading(message = '분석 중입니다...') { + const loadingElement = document.getElementById('loadingState'); + if (loadingElement) { + const textElement = loadingElement.querySelector('.loading-text'); + if (textElement) { + textElement.textContent = message; + } + loadingElement.style.display = 'flex'; + } + } + + /** + * 로딩 숨김 + */ + hideLoading() { + const loadingElement = document.getElementById('loadingState'); + if (loadingElement) { + loadingElement.style.display = 'none'; + } + } + + /** + * 토스트 메시지 표시 + */ + showToast(message, type = 'info') { + console.log(`📢 ${type.toUpperCase()}: ${message}`); + + // 간단한 토스트 구현 (실제로는 더 정교한 토스트 라이브러리 사용 권장) + if (type === 'error') { + alert(`❌ ${message}`); + } else if (type === 'success') { + console.log(`✅ ${message}`); + } else if (type === 'warning') { + alert(`⚠️ ${message}`); + } + } + + /** + * 에러 처리 + */ + handleError(errorInfo) { + console.error('❌ 에러 발생:', errorInfo); + this.showToast(errorInfo.message, 'error'); + } + + // ========== 유틸리티 ========== + + /** + * 컨트롤러 상태 디버그 + */ + debug() { + console.log('🔍 메인 컨트롤러 상태:'); + console.log('- API 클라이언트:', this.api); + console.log('- 상태 관리자:', this.state.getState()); + console.log('- 차트 상태:', this.chartRenderer.getChartStatus()); + } +} + +// 전역 인스턴스 생성 및 초기화 +document.addEventListener('DOMContentLoaded', () => { + window.WorkAnalysisMainController = new WorkAnalysisMainController(); +}); + +// Export는 브라우저 환경에서 제거됨 diff --git a/web-ui/js/work-analysis/module-loader.js b/web-ui/js/work-analysis/module-loader.js new file mode 100644 index 0000000..b68ac64 --- /dev/null +++ b/web-ui/js/work-analysis/module-loader.js @@ -0,0 +1,267 @@ +/** + * Work Analysis Module Loader + * 작업 분석 모듈들을 순서대로 로드하고 초기화하는 로더 + */ + +class WorkAnalysisModuleLoader { + constructor() { + this.modules = [ + { name: 'API Client', path: '/js/work-analysis/api-client.js', loaded: false }, + { name: 'Data Processor', path: '/js/work-analysis/data-processor.js', loaded: false }, + { name: 'State Manager', path: '/js/work-analysis/state-manager.js', loaded: false }, + { name: 'Table Renderer', path: '/js/work-analysis/table-renderer.js', loaded: false }, + { name: 'Chart Renderer', path: '/js/work-analysis/chart-renderer.js', loaded: false }, + { name: 'Main Controller', path: '/js/work-analysis/main-controller.js', loaded: false } + ]; + + this.loadingPromise = null; + } + + /** + * 모든 모듈 로드 + * @returns {Promise} 로딩 완료 Promise + */ + async loadAll() { + if (this.loadingPromise) { + return this.loadingPromise; + } + + this.loadingPromise = this._loadModules(); + return this.loadingPromise; + } + + /** + * 모듈들을 순차적으로 로드 + */ + async _loadModules() { + console.log('🚀 작업 분석 모듈 로딩 시작'); + + try { + // 의존성 순서대로 로드 + for (const module of this.modules) { + await this._loadModule(module); + } + + console.log('✅ 모든 작업 분석 모듈 로딩 완료'); + this._onAllModulesLoaded(); + + } catch (error) { + console.error('❌ 모듈 로딩 실패:', error); + this._onLoadingError(error); + throw error; + } + } + + /** + * 개별 모듈 로드 + * @param {Object} module - 모듈 정보 + */ + async _loadModule(module) { + return new Promise((resolve, reject) => { + console.log(`📦 로딩 중: ${module.name}`); + + const script = document.createElement('script'); + script.src = module.path; + script.type = 'text/javascript'; + + script.onload = () => { + module.loaded = true; + console.log(`✅ 로딩 완료: ${module.name}`); + resolve(); + }; + + script.onerror = (error) => { + console.error(`❌ 로딩 실패: ${module.name}`, error); + reject(new Error(`Failed to load ${module.name}: ${module.path}`)); + }; + + document.head.appendChild(script); + }); + } + + /** + * 모든 모듈 로딩 완료 시 호출 + */ + _onAllModulesLoaded() { + // 전역 변수 확인 + const requiredGlobals = [ + 'WorkAnalysisAPI', + 'WorkAnalysisDataProcessor', + 'WorkAnalysisState', + 'WorkAnalysisTableRenderer', + 'WorkAnalysisChartRenderer' + ]; + + const missingGlobals = requiredGlobals.filter(name => !window[name]); + + if (missingGlobals.length > 0) { + console.warn('⚠️ 일부 전역 객체가 누락됨:', missingGlobals); + } + + // 하위 호환성을 위한 전역 함수들 설정 + this._setupLegacyFunctions(); + + // 모듈 로딩 완료 이벤트 발생 + window.dispatchEvent(new CustomEvent('workAnalysisModulesLoaded', { + detail: { modules: this.modules } + })); + + console.log('🎉 작업 분석 시스템 준비 완료'); + } + + /** + * 하위 호환성을 위한 전역 함수 설정 + */ + _setupLegacyFunctions() { + // 기존 HTML에서 사용하던 함수들을 새 모듈 시스템으로 연결 + const legacyFunctions = { + // 기간 확정 + confirmPeriod: () => { + if (window.WorkAnalysisMainController) { + window.WorkAnalysisMainController.handlePeriodConfirm(); + } + }, + + // 분석 모드 변경 + switchAnalysisMode: (mode) => { + if (window.WorkAnalysisState) { + window.WorkAnalysisState.setAnalysisMode(mode); + } + }, + + // 탭 변경 + switchTab: (tabId) => { + if (window.WorkAnalysisState) { + window.WorkAnalysisState.setCurrentTab(tabId); + } + }, + + // 개별 분석 함수들 + analyzeWorkStatus: () => { + if (window.WorkAnalysisMainController) { + window.WorkAnalysisMainController.analyzeWorkStatus(); + } + }, + + analyzeProjectDistribution: () => { + if (window.WorkAnalysisMainController) { + window.WorkAnalysisMainController.analyzeProjectDistribution(); + } + }, + + analyzeWorkerPerformance: () => { + if (window.WorkAnalysisMainController) { + window.WorkAnalysisMainController.analyzeWorkerPerformance(); + } + }, + + analyzeErrorAnalysis: () => { + if (window.WorkAnalysisMainController) { + window.WorkAnalysisMainController.analyzeErrorAnalysis(); + } + } + }; + + // 전역 함수로 등록 + Object.assign(window, legacyFunctions); + + console.log('🔗 하위 호환성 함수 설정 완료'); + } + + /** + * 로딩 에러 처리 + */ + _onLoadingError(error) { + // 에러 UI 표시 + const container = document.querySelector('.analysis-container'); + if (container) { + const errorHTML = ` +
+
⚠️
+

모듈 로딩 실패

+

+ 작업 분석 시스템을 로드하는 중 오류가 발생했습니다.
+ 페이지를 새로고침하거나 관리자에게 문의하세요. +

+ +
+ 기술적 세부사항 +
${error.message}
+
+
+ `; + + container.innerHTML = errorHTML; + } + } + + /** + * 로딩 상태 확인 + * @returns {Object} 로딩 상태 정보 + */ + getLoadingStatus() { + const total = this.modules.length; + const loaded = this.modules.filter(m => m.loaded).length; + + return { + total, + loaded, + percentage: Math.round((loaded / total) * 100), + isComplete: loaded === total, + modules: this.modules.map(m => ({ + name: m.name, + loaded: m.loaded + })) + }; + } + + /** + * 특정 모듈 로딩 상태 확인 + * @param {string} moduleName - 모듈명 + * @returns {boolean} 로딩 완료 여부 + */ + isModuleLoaded(moduleName) { + const module = this.modules.find(m => m.name === moduleName); + return module ? module.loaded : false; + } +} + +// 전역 인스턴스 생성 +window.WorkAnalysisModuleLoader = new WorkAnalysisModuleLoader(); + +// 자동 로딩 시작 (DOM이 준비되면) +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.WorkAnalysisModuleLoader.loadAll(); + }); +} else { + // DOM이 이미 준비된 경우 즉시 로딩 + window.WorkAnalysisModuleLoader.loadAll(); +} + +// Export는 브라우저 환경에서 제거됨 diff --git a/web-ui/js/work-analysis/state-manager.js b/web-ui/js/work-analysis/state-manager.js new file mode 100644 index 0000000..0668bdf --- /dev/null +++ b/web-ui/js/work-analysis/state-manager.js @@ -0,0 +1,382 @@ +/** + * Work Analysis State Manager Module + * 작업 분석 페이지의 상태 관리를 담당하는 모듈 + */ + +class WorkAnalysisStateManager { + constructor() { + this.state = { + // 분석 설정 + analysisMode: 'period', // 'period' | 'project' + confirmedPeriod: { + start: null, + end: null, + confirmed: false + }, + + // UI 상태 + currentTab: 'work-status', + isAnalysisEnabled: false, + isLoading: false, + + // 데이터 캐시 + cache: { + basicStats: null, + chartData: null, + projectDistribution: null, + errorAnalysis: null + }, + + // 에러 상태 + lastError: null + }; + + this.listeners = new Map(); + this.init(); + } + + /** + * 초기화 + */ + init() { + console.log('🔧 상태 관리자 초기화'); + + // 기본 날짜 설정 (현재 월) + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + this.updateState({ + confirmedPeriod: { + start: this.formatDate(startOfMonth), + end: this.formatDate(endOfMonth), + confirmed: false + } + }); + } + + /** + * 상태 업데이트 + * @param {Object} updates - 업데이트할 상태 + */ + updateState(updates) { + const prevState = { ...this.state }; + this.state = { ...this.state, ...updates }; + + console.log('🔄 상태 업데이트:', updates); + + // 리스너들에게 상태 변경 알림 + this.notifyListeners(prevState, this.state); + } + + /** + * 상태 리스너 등록 + * @param {string} key - 리스너 키 + * @param {Function} callback - 콜백 함수 + */ + subscribe(key, callback) { + this.listeners.set(key, callback); + } + + /** + * 상태 리스너 제거 + * @param {string} key - 리스너 키 + */ + unsubscribe(key) { + this.listeners.delete(key); + } + + /** + * 리스너들에게 알림 + */ + notifyListeners(prevState, newState) { + this.listeners.forEach((callback, key) => { + try { + callback(newState, prevState); + } catch (error) { + console.error(`❌ 리스너 ${key} 오류:`, error); + } + }); + } + + // ========== 분석 설정 관리 ========== + + /** + * 분석 모드 변경 + * @param {string} mode - 분석 모드 ('period' | 'project') + */ + setAnalysisMode(mode) { + if (mode !== 'period' && mode !== 'project') { + throw new Error('유효하지 않은 분석 모드입니다.'); + } + + this.updateState({ + analysisMode: mode, + currentTab: 'work-status' // 모드 변경 시 첫 번째 탭으로 리셋 + }); + } + + /** + * 기간 확정 + * @param {string} startDate - 시작일 + * @param {string} endDate - 종료일 + */ + confirmPeriod(startDate, endDate) { + // 날짜 유효성 검사 + if (!startDate || !endDate) { + throw new Error('시작일과 종료일을 모두 입력해주세요.'); + } + + const start = new Date(startDate); + const end = new Date(endDate); + + if (start > end) { + throw new Error('시작일이 종료일보다 늦을 수 없습니다.'); + } + + // 최대 1년 제한 + const maxDays = 365; + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)); + if (daysDiff > maxDays) { + throw new Error(`분석 기간은 최대 ${maxDays}일까지 가능합니다.`); + } + + this.updateState({ + confirmedPeriod: { + start: startDate, + end: endDate, + confirmed: true + }, + isAnalysisEnabled: true, + // 기간 변경 시 캐시 초기화 + cache: { + basicStats: null, + chartData: null, + projectDistribution: null, + errorAnalysis: null + } + }); + + console.log('✅ 기간 확정:', startDate, '~', endDate); + } + + /** + * 현재 탭 변경 + * @param {string} tabId - 탭 ID + */ + setCurrentTab(tabId) { + const validTabs = ['work-status', 'project-distribution', 'worker-performance', 'error-analysis']; + + if (!validTabs.includes(tabId)) { + console.warn('유효하지 않은 탭 ID:', tabId); + return; + } + + this.updateState({ currentTab: tabId }); + + // DOM 업데이트 직접 수행 + this.updateTabDOM(tabId); + + console.log('🔄 탭 전환:', tabId); + } + + /** + * 탭 DOM 업데이트 + * @param {string} tabId - 탭 ID + */ + updateTabDOM(tabId) { + // 탭 버튼 업데이트 + document.querySelectorAll('.tab-button').forEach(button => { + button.classList.remove('active'); + if (button.dataset.tab === tabId) { + button.classList.add('active'); + } + }); + + // 탭 컨텐츠 업데이트 + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.remove('active'); + if (content.id === `${tabId}-tab`) { + content.classList.add('active'); + } + }); + } + + // ========== 로딩 상태 관리 ========== + + /** + * 로딩 시작 + * @param {string} message - 로딩 메시지 + */ + startLoading(message = '분석 중입니다...') { + this.updateState({ + isLoading: true, + loadingMessage: message + }); + } + + /** + * 로딩 종료 + */ + stopLoading() { + this.updateState({ + isLoading: false, + loadingMessage: null + }); + } + + // ========== 데이터 캐시 관리 ========== + + /** + * 캐시 데이터 저장 + * @param {string} key - 캐시 키 + * @param {*} data - 저장할 데이터 + */ + setCache(key, data) { + this.updateState({ + cache: { + ...this.state.cache, + [key]: { + data, + timestamp: Date.now() + } + } + }); + } + + /** + * 캐시 데이터 조회 + * @param {string} key - 캐시 키 + * @param {number} maxAge - 최대 유효 시간 (밀리초) + * @returns {*} 캐시된 데이터 또는 null + */ + getCache(key, maxAge = 5 * 60 * 1000) { // 기본 5분 + const cached = this.state.cache[key]; + + if (!cached) { + return null; + } + + const age = Date.now() - cached.timestamp; + if (age > maxAge) { + console.log('🗑️ 캐시 만료:', key); + return null; + } + + console.log('📦 캐시 히트:', key); + return cached.data; + } + + /** + * 캐시 초기화 + * @param {string} key - 특정 키만 초기화 (선택사항) + */ + clearCache(key = null) { + if (key) { + this.updateState({ + cache: { + ...this.state.cache, + [key]: null + } + }); + } else { + this.updateState({ + cache: { + basicStats: null, + chartData: null, + projectDistribution: null, + errorAnalysis: null + } + }); + } + } + + // ========== 에러 관리 ========== + + /** + * 에러 설정 + * @param {Error|string} error - 에러 객체 또는 메시지 + */ + setError(error) { + const errorInfo = { + message: error instanceof Error ? error.message : error, + timestamp: Date.now(), + stack: error instanceof Error ? error.stack : null + }; + + this.updateState({ + lastError: errorInfo, + isLoading: false + }); + + console.error('❌ 에러 발생:', errorInfo); + } + + /** + * 에러 초기화 + */ + clearError() { + this.updateState({ lastError: null }); + } + + // ========== 유틸리티 ========== + + /** + * 날짜 포맷팅 + * @param {Date} date - 날짜 객체 + * @returns {string} YYYY-MM-DD 형식 + */ + formatDate(date) { + return date.toISOString().split('T')[0]; + } + + /** + * 현재 상태 조회 + * @returns {Object} 현재 상태 + */ + getState() { + return { ...this.state }; + } + + /** + * 분석 가능 여부 확인 + * @returns {boolean} 분석 가능 여부 + */ + canAnalyze() { + return this.state.confirmedPeriod.confirmed && + this.state.confirmedPeriod.start && + this.state.confirmedPeriod.end && + !this.state.isLoading; + } + + /** + * 상태 디버그 정보 출력 + */ + debug() { + console.log('🔍 현재 상태:', this.state); + console.log('👂 등록된 리스너:', Array.from(this.listeners.keys())); + } +} + +// 전역 인스턴스 생성 +window.WorkAnalysisState = new WorkAnalysisStateManager(); + +// 하위 호환성을 위한 전역 변수들 +Object.defineProperty(window, 'currentAnalysisMode', { + get: () => window.WorkAnalysisState.state.analysisMode, + set: (value) => window.WorkAnalysisState.setAnalysisMode(value) +}); + +Object.defineProperty(window, 'confirmedStartDate', { + get: () => window.WorkAnalysisState.state.confirmedPeriod.start +}); + +Object.defineProperty(window, 'confirmedEndDate', { + get: () => window.WorkAnalysisState.state.confirmedPeriod.end +}); + +Object.defineProperty(window, 'isAnalysisEnabled', { + get: () => window.WorkAnalysisState.state.isAnalysisEnabled +}); + +// Export는 브라우저 환경에서 제거됨 diff --git a/web-ui/js/work-analysis/table-renderer.js b/web-ui/js/work-analysis/table-renderer.js new file mode 100644 index 0000000..6572f04 --- /dev/null +++ b/web-ui/js/work-analysis/table-renderer.js @@ -0,0 +1,510 @@ +/** + * 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(` + + ${displayText} + 데이터 없음 + ${manDays} + ${loadRate}% + ₩${laborCost.toLocaleString()} + + `); + } 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(` + + ${isFirstWorkType ? `${displayText}` : ''} + ${workType.work_type_name} + ${manDays} + ${loadRate}% + ₩${laborCost.toLocaleString()} + + `); + }); + + // 프로젝트 소계 행 추가 + 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(` + + ${projectName} 소계 + ${projectTotalManDays} + ${projectLoadRate}% + ₩${projectTotalLaborCost.toLocaleString()} + + `); + } + }); + + // 테이블 업데이트 + 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 = ` + + + 해당 기간에 데이터가 없습니다 + + + `; + 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 ` + + 작업자 기반 + ${worker.worker_name} + ${manDays} + - + ₩${laborCost.toLocaleString()} + + `; + }); + + 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 = ` + + + 해당 기간에 오류 데이터가 없습니다 + + + `; + 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(`정규: ${workType.regularHours}h`); + } + + // 오류 세부사항 추가 + workType.errorDetails.forEach(error => { + detailHours.push(`오류: ${error.type} ${error.hours}h`); + }); + + // 작업 타입 구성 (단순화) + let workTypeDisplay = ''; + if (workType.regularHours > 0) { + workTypeDisplay += ` +
+ 정규시간 +
+ `; + } + + workType.errorDetails.forEach(error => { + workTypeDisplay += ` +
+ 오류: ${error.type} +
+ `; + }); + + tableRows.push(` + + ${isFirstWorkType ? `${workType.isVacation ? '연차/휴무' : (workType.project_name || 'N/A')}` : ''} + ${workType.work_type_name} + ${workType.totalHours}h + + ${detailHours.join('
')} + + +
+ ${workTypeDisplay} +
+ + ${workType.errorRate}% + + `); + }); + }); + + if (tableRows.length === 0) { + tableBody.innerHTML = ` + + + 해당 기간에 작업 데이터가 없습니다 + + + `; + 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 = ` + 정규: ${grandTotalRegularHours}h
오류: ${grandTotalErrorHours}h
+ `; + } + + if (errorRateCell) { + errorRateCell.innerHTML = `${totalErrorRate}%`; + } + + 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 = ` +
+
📊
+
데이터가 없습니다
+
선택한 기간에 작업 데이터가 없습니다.
+
+ `; + return; + } + + // 작업자별 데이터 처리 + const workerStats = this._processWorkerStats(workerData, recentWorkData); + + let tableHTML = ` + + + + + + + + + + + + + + `; + + 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 += ` + + ${isFirstProject ? ` + + ` : ''} + + + + ${isFirstProject ? ` + + + ` : ''} + + + `; + }); + }); + }); + + tableHTML += ` + + + + + + + + + +
작업자분류(프로젝트)작업내용투입시간작업공수작업일/일평균시간비고
${worker.name}${project.name}${workType.name}${workType.hours}h${worker.totalManDays.toFixed(1)}${worker.workDays}일 / ${worker.avgHours.toFixed(1)}h${workType.remarks}
총 공수${totalHours}h${totalManDays.toFixed(1)}
+ `; + + 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는 브라우저 환경에서 제거됨 diff --git a/web-ui/pages/analysis/work-analysis-legacy.html b/web-ui/pages/analysis/work-analysis-legacy.html new file mode 100644 index 0000000..52e1158 --- /dev/null +++ b/web-ui/pages/analysis/work-analysis-legacy.html @@ -0,0 +1,2233 @@ + + + + + + 작업 분석 | (주)테크니컬코리아 + + + + + + + + + + +
+ + + + + + + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + 📊 + 대시보드 + +
+ + + + + + + + + + \ No newline at end of file diff --git a/web-ui/pages/analysis/work-analysis-modular.html b/web-ui/pages/analysis/work-analysis-modular.html new file mode 100644 index 0000000..94b9e26 --- /dev/null +++ b/web-ui/pages/analysis/work-analysis-modular.html @@ -0,0 +1,363 @@ + + + + + + 작업 분석 | (주)테크니컬코리아 + + + + + + + + + + +
+ + + + + + + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + +
+ + + + + + + + +
+
+ + + + + + + diff --git a/web-ui/pages/analysis/work-analysis.html b/web-ui/pages/analysis/work-analysis.html index f74457f..1ccad19 100644 --- a/web-ui/pages/analysis/work-analysis.html +++ b/web-ui/pages/analysis/work-analysis.html @@ -105,83 +105,9 @@ - + @@ -388,56 +314,819 @@ + + + + +
+ + + + + + + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + +
+ + + + + + + + + + + + + + +
+
+ + +
+ + 📊 + 대시보드 + +
+ + + + + + + \ No newline at end of file diff --git a/web-ui/pages/dashboard/group-leader.html b/web-ui/pages/dashboard/group-leader.html index d2f4530..4136eaa 100644 --- a/web-ui/pages/dashboard/group-leader.html +++ b/web-ui/pages/dashboard/group-leader.html @@ -13,7 +13,7 @@ - +