From ed40eec2610af92b391c9572cbc3807476512794 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 5 Nov 2025 10:12:52 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EB=A6=AC=EB=8D=94?= =?UTF-8?q?=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5/=EC=82=AD=EC=A0=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20=EC=9E=91=EC=97=85=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ”§ κ·Έλ£Ή 리더 λŒ€μ‹œλ³΄λ“œ μˆ˜μ •μ‚¬ν•­: - API 호좜 방식 μˆ˜μ • (modern-dashboard.js) - μ„œλ²„ API μš”κ΅¬μ‚¬ν•­μ— λ§žλŠ” 데이터 ꡬ쑰 λ³€κ²½ - work_entries λ°°μ—΄ ꡬ쑰둜 λ³€κ²½ - work_type_id β†’ task_id ν•„λ“œλͺ… λ§€ν•‘ - 400 Bad Request 였λ₯˜ ν•΄κ²° ⚑ μž‘μ—… 뢄석 μ‹œμŠ€ν…œ μ„±λŠ₯ μ΅œμ ν™”: - 쀑볡 ν•¨μˆ˜ 제거 (isWeekend, isVacationProject 톡합) - WorkAnalysisAPI 캐싱 μ‹œμŠ€ν…œ κ΅¬ν˜„ (5λΆ„ 만료) - λ„€μž„μŠ€νŽ˜μ΄μŠ€ 쑰직화 (utils, ui, analysis, render) - ErrorHandler 톡합 μ—λŸ¬ 처리 μ‹œμŠ€ν…œ - μ„±λŠ₯ λͺ¨λ‹ˆν„°λ§ 및 λ©”λͺ¨λ¦¬ λˆ„μˆ˜ λ°©μ§€ - GPU 가속 CSS μ• λ‹ˆλ©”μ΄μ…˜ μΆ”κ°€ - λ””λ°”μš΄μŠ€/μŠ€λ‘œν‹€ ν•¨μˆ˜ 적용 - 의미 μ—†λŠ” 톡계 μΉ΄λ“œ 제거 πŸ“Š μž‘μ—… 뢄석 νŽ˜μ΄μ§€ κ°œμ„ : - ν”„λ‘œκ·Έλ ˆμŠ€ λ°” μ• λ‹ˆλ©”μ΄μ…˜ - ν† μŠ€νŠΈ μ•Œλ¦Ό μ‹œμŠ€ν…œ - λΆ€λ“œλŸ¬μš΄ μ „ν™˜ 효과 - λ°˜μ‘ν˜• μ΅œμ ν™” - λ©”λͺ¨λ¦¬ μ‚¬μš©λŸ‰ λͺ¨λ‹ˆν„°λ§ --- web-ui/css/work-analysis.css | 192 +- web-ui/js/modern-dashboard.js | 41 +- web-ui/js/work-analysis/api-client.js | 231 ++ web-ui/js/work-analysis/chart-renderer.js | 455 ++++ web-ui/js/work-analysis/data-processor.js | 355 +++ web-ui/js/work-analysis/main-controller.js | 612 +++++ web-ui/js/work-analysis/module-loader.js | 267 ++ web-ui/js/work-analysis/state-manager.js | 382 +++ web-ui/js/work-analysis/table-renderer.js | 510 ++++ .../pages/analysis/work-analysis-legacy.html | 2233 +++++++++++++++++ .../pages/analysis/work-analysis-modular.html | 363 +++ web-ui/pages/analysis/work-analysis.html | 1110 ++++++-- .../pages/analysis/work-analysis.html.backup | 2230 ++++++++++++++++ web-ui/pages/dashboard/group-leader.html | 2 +- 14 files changed, 8740 insertions(+), 243 deletions(-) create mode 100644 web-ui/js/work-analysis/api-client.js create mode 100644 web-ui/js/work-analysis/chart-renderer.js create mode 100644 web-ui/js/work-analysis/data-processor.js create mode 100644 web-ui/js/work-analysis/main-controller.js create mode 100644 web-ui/js/work-analysis/module-loader.js create mode 100644 web-ui/js/work-analysis/state-manager.js create mode 100644 web-ui/js/work-analysis/table-renderer.js create mode 100644 web-ui/pages/analysis/work-analysis-legacy.html create mode 100644 web-ui/pages/analysis/work-analysis-modular.html create mode 100644 web-ui/pages/analysis/work-analysis.html.backup 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 @@ - +