fix: 그룹 리더 대시보드 작업 저장/삭제 오류 해결 및 작업 분석 시스템 성능 최적화
🔧 그룹 리더 대시보드 수정사항: - API 호출 방식 수정 (modern-dashboard.js) - 서버 API 요구사항에 맞는 데이터 구조 변경 - work_entries 배열 구조로 변경 - work_type_id → task_id 필드명 매핑 - 400 Bad Request 오류 해결 ⚡ 작업 분석 시스템 성능 최적화: - 중복 함수 제거 (isWeekend, isVacationProject 통합) - WorkAnalysisAPI 캐싱 시스템 구현 (5분 만료) - 네임스페이스 조직화 (utils, ui, analysis, render) - ErrorHandler 통합 에러 처리 시스템 - 성능 모니터링 및 메모리 누수 방지 - GPU 가속 CSS 애니메이션 추가 - 디바운스/스로틀 함수 적용 - 의미 없는 통계 카드 제거 📊 작업 분석 페이지 개선: - 프로그레스 바 애니메이션 - 토스트 알림 시스템 - 부드러운 전환 효과 - 반응형 최적화 - 메모리 사용량 모니터링
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
231
web-ui/js/work-analysis/api-client.js
Normal file
231
web-ui/js/work-analysis/api-client.js
Normal file
@@ -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<Object>} 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<Array>} 결과 배열
|
||||
*/
|
||||
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는 브라우저 환경에서 제거됨
|
||||
455
web-ui/js/work-analysis/chart-renderer.js
Normal file
455
web-ui/js/work-analysis/chart-renderer.js
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Work Analysis Chart Renderer Module
|
||||
* 작업 분석 차트 렌더링을 담당하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisChartRenderer {
|
||||
constructor() {
|
||||
this.charts = new Map(); // 차트 인스턴스 관리
|
||||
this.dataProcessor = window.WorkAnalysisDataProcessor;
|
||||
this.defaultColors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
|
||||
];
|
||||
}
|
||||
|
||||
// ========== 차트 관리 ==========
|
||||
|
||||
/**
|
||||
* 기존 차트 제거
|
||||
* @param {string} chartId - 차트 ID
|
||||
*/
|
||||
destroyChart(chartId) {
|
||||
if (this.charts.has(chartId)) {
|
||||
this.charts.get(chartId).destroy();
|
||||
this.charts.delete(chartId);
|
||||
console.log('🗑️ 차트 제거:', chartId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 차트 제거
|
||||
*/
|
||||
destroyAllCharts() {
|
||||
this.charts.forEach((chart, id) => {
|
||||
chart.destroy();
|
||||
console.log('🗑️ 차트 제거:', id);
|
||||
});
|
||||
this.charts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 생성 및 등록
|
||||
* @param {string} chartId - 차트 ID
|
||||
* @param {HTMLCanvasElement} canvas - 캔버스 요소
|
||||
* @param {Object} config - 차트 설정
|
||||
* @returns {Chart} 생성된 차트 인스턴스
|
||||
*/
|
||||
createChart(chartId, canvas, config) {
|
||||
// 기존 차트가 있으면 제거
|
||||
this.destroyChart(chartId);
|
||||
|
||||
const chart = new Chart(canvas, config);
|
||||
this.charts.set(chartId, chart);
|
||||
|
||||
console.log('📊 차트 생성:', chartId);
|
||||
return chart;
|
||||
}
|
||||
|
||||
// ========== 시계열 차트 ==========
|
||||
|
||||
/**
|
||||
* 시계열 차트 렌더링 (기간별 작업 현황)
|
||||
* @param {string} startDate - 시작일
|
||||
* @param {string} endDate - 종료일
|
||||
* @param {string} projectId - 프로젝트 ID (선택사항)
|
||||
*/
|
||||
async renderTimeSeriesChart(startDate, endDate, projectId = '') {
|
||||
console.log('📈 시계열 차트 렌더링 시작');
|
||||
|
||||
try {
|
||||
const api = window.WorkAnalysisAPI;
|
||||
const dailyTrendResponse = await api.getDailyTrend(startDate, endDate, projectId);
|
||||
|
||||
if (!dailyTrendResponse.success || !dailyTrendResponse.data) {
|
||||
throw new Error('일별 추이 데이터를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('workStatusChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ workStatusChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processTimeSeriesData(dailyTrendResponse.data);
|
||||
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 2,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업시간 (h)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업자 수 (명)'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '일별 작업 현황'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('workStatus', canvas, config);
|
||||
console.log('✅ 시계열 차트 렌더링 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 시계열 차트 렌더링 실패:', error);
|
||||
this._showChartError('workStatusChart', '시계열 차트를 불러올 수 없습니다');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 스택 바 차트 ==========
|
||||
|
||||
/**
|
||||
* 스택 바 차트 렌더링 (프로젝트별 → 작업유형별)
|
||||
* @param {Array} projectData - 프로젝트 데이터
|
||||
*/
|
||||
renderStackedBarChart(projectData) {
|
||||
console.log('📊 스택 바 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('projectDistributionChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ projectDistributionChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
|
||||
this._showChartError('projectDistributionChart', '프로젝트 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 변환
|
||||
const { labels, datasets } = this._processStackedBarData(projectData.projects);
|
||||
|
||||
const config = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 2,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업시간 (h)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트별 작업유형 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
return `${context[0].label}`;
|
||||
},
|
||||
label: function(context) {
|
||||
const workType = context.dataset.label;
|
||||
const hours = context.parsed.y;
|
||||
const percentage = ((hours / projectData.totalHours) * 100).toFixed(1);
|
||||
return `${workType}: ${hours}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('projectDistribution', canvas, config);
|
||||
console.log('✅ 스택 바 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 스택 바 차트 데이터 처리
|
||||
*/
|
||||
_processStackedBarData(projects) {
|
||||
// 모든 작업유형 수집
|
||||
const allWorkTypes = new Set();
|
||||
projects.forEach(project => {
|
||||
project.workTypes.forEach(wt => {
|
||||
allWorkTypes.add(wt.work_type_name);
|
||||
});
|
||||
});
|
||||
|
||||
const workTypeArray = Array.from(allWorkTypes);
|
||||
const labels = projects.map(p => p.project_name);
|
||||
|
||||
// 작업유형별 데이터셋 생성
|
||||
const datasets = workTypeArray.map((workTypeName, index) => {
|
||||
const data = projects.map(project => {
|
||||
const workType = project.workTypes.find(wt => wt.work_type_name === workTypeName);
|
||||
return workType ? workType.totalHours : 0;
|
||||
});
|
||||
|
||||
return {
|
||||
label: workTypeName,
|
||||
data,
|
||||
backgroundColor: this.defaultColors[index % this.defaultColors.length],
|
||||
borderColor: this.defaultColors[index % this.defaultColors.length],
|
||||
borderWidth: 1
|
||||
};
|
||||
});
|
||||
|
||||
return { labels, datasets };
|
||||
}
|
||||
|
||||
// ========== 도넛 차트 ==========
|
||||
|
||||
/**
|
||||
* 도넛 차트 렌더링 (작업자별 성과)
|
||||
* @param {Array} workerData - 작업자 데이터
|
||||
*/
|
||||
renderWorkerPerformanceChart(workerData) {
|
||||
console.log('👤 작업자별 성과 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('workerPerformanceChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ workerPerformanceChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workerData || workerData.length === 0) {
|
||||
this._showChartError('workerPerformanceChart', '작업자 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processDonutChartData(
|
||||
workerData.map(worker => ({
|
||||
name: worker.worker_name,
|
||||
hours: worker.totalHours
|
||||
}))
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 1,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업자별 작업시간 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'right'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label;
|
||||
const value = context.parsed;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('workerPerformance', canvas, config);
|
||||
console.log('✅ 작업자별 성과 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 오류 분석 차트 ==========
|
||||
|
||||
/**
|
||||
* 오류 분석 차트 렌더링
|
||||
* @param {Array} errorData - 오류 데이터
|
||||
*/
|
||||
renderErrorAnalysisChart(errorData) {
|
||||
console.log('⚠️ 오류 분석 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('errorAnalysisChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ errorAnalysisChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!errorData || errorData.length === 0) {
|
||||
this._showChartError('errorAnalysisChart', '오류 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 오류가 있는 데이터만 필터링
|
||||
const errorItems = errorData.filter(item =>
|
||||
item.error_count > 0 || (item.errorDetails && item.errorDetails.length > 0)
|
||||
);
|
||||
|
||||
if (errorItems.length === 0) {
|
||||
this._showChartError('errorAnalysisChart', '오류가 발생한 항목이 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processDonutChartData(
|
||||
errorItems.map(item => ({
|
||||
name: item.project_name || item.name,
|
||||
hours: item.errorHours || item.error_count
|
||||
}))
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 1,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트별 오류 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label;
|
||||
const value = context.parsed;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('errorAnalysis', canvas, config);
|
||||
console.log('✅ 오류 분석 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
|
||||
/**
|
||||
* 차트 오류 표시
|
||||
* @param {string} canvasId - 캔버스 ID
|
||||
* @param {string} message - 오류 메시지
|
||||
*/
|
||||
_showChartError(canvasId, message) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
|
||||
const container = canvas.parentElement;
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">차트를 표시할 수 없습니다</div>
|
||||
<div style="font-size: 0.9rem;">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 리사이즈
|
||||
*/
|
||||
resizeCharts() {
|
||||
this.charts.forEach((chart, id) => {
|
||||
try {
|
||||
chart.resize();
|
||||
console.log('📏 차트 리사이즈:', id);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 차트 리사이즈 실패:', id, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 상태 확인
|
||||
*/
|
||||
getChartStatus() {
|
||||
const status = {};
|
||||
this.charts.forEach((chart, id) => {
|
||||
status[id] = {
|
||||
type: chart.config.type,
|
||||
datasetCount: chart.data.datasets.length,
|
||||
dataPointCount: chart.data.labels ? chart.data.labels.length : 0
|
||||
};
|
||||
});
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisChartRenderer = new WorkAnalysisChartRenderer();
|
||||
|
||||
// 윈도우 리사이즈 이벤트 리스너
|
||||
window.addEventListener('resize', () => {
|
||||
window.WorkAnalysisChartRenderer.resizeCharts();
|
||||
});
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
355
web-ui/js/work-analysis/data-processor.js
Normal file
355
web-ui/js/work-analysis/data-processor.js
Normal file
@@ -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는 브라우저 환경에서 제거됨
|
||||
612
web-ui/js/work-analysis/main-controller.js
Normal file
612
web-ui/js/work-analysis/main-controller.js
Normal file
@@ -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는 브라우저 환경에서 제거됨
|
||||
267
web-ui/js/work-analysis/module-loader.js
Normal file
267
web-ui/js/work-analysis/module-loader.js
Normal file
@@ -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 = `
|
||||
<div style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 50vh;
|
||||
text-align: center;
|
||||
color: #ef4444;
|
||||
">
|
||||
<div style="font-size: 4rem; margin-bottom: 1rem;">⚠️</div>
|
||||
<h2 style="margin-bottom: 1rem;">모듈 로딩 실패</h2>
|
||||
<p style="margin-bottom: 2rem; color: #666;">
|
||||
작업 분석 시스템을 로드하는 중 오류가 발생했습니다.<br>
|
||||
페이지를 새로고침하거나 관리자에게 문의하세요.
|
||||
</p>
|
||||
<button onclick="location.reload()" style="
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
">
|
||||
페이지 새로고침
|
||||
</button>
|
||||
<details style="margin-top: 2rem; text-align: left; max-width: 600px;">
|
||||
<summary style="cursor: pointer; color: #666;">기술적 세부사항</summary>
|
||||
<pre style="
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875rem;
|
||||
">${error.message}</pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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는 브라우저 환경에서 제거됨
|
||||
382
web-ui/js/work-analysis/state-manager.js
Normal file
382
web-ui/js/work-analysis/state-manager.js
Normal file
@@ -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는 브라우저 환경에서 제거됨
|
||||
510
web-ui/js/work-analysis/table-renderer.js
Normal file
510
web-ui/js/work-analysis/table-renderer.js
Normal file
@@ -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(`
|
||||
<tr class="project-group ${isVacation ? 'vacation-project' : ''}">
|
||||
<td class="project-name">${displayText}</td>
|
||||
<td class="work-content">데이터 없음</td>
|
||||
<td class="man-days">${manDays}</td>
|
||||
<td class="load-rate">${loadRate}%</td>
|
||||
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`);
|
||||
} else {
|
||||
// 작업유형별 렌더링
|
||||
workTypes.forEach((workType, index) => {
|
||||
const isFirstWorkType = index === 0;
|
||||
const rowspan = workTypes.length;
|
||||
const workTypeHours = workType.totalHours || 0;
|
||||
const manDays = Math.round((workTypeHours / 8) * 100) / 100;
|
||||
const laborCost = manDays * manDayRate;
|
||||
const loadRate = grandTotalHours > 0 ? ((workTypeHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
|
||||
grandTotalManDays += manDays;
|
||||
grandTotalLaborCost += laborCost;
|
||||
|
||||
const isVacation = project.project_id === 'vacation';
|
||||
const displayText = isVacation ? projectName : jobNo;
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="project-group ${isVacation ? 'vacation-project' : ''}">
|
||||
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${displayText}</td>` : ''}
|
||||
<td class="work-content">${workType.work_type_name}</td>
|
||||
<td class="man-days">${manDays}</td>
|
||||
<td class="load-rate">${loadRate}%</td>
|
||||
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
// 프로젝트 소계 행 추가
|
||||
const projectTotalHours = workTypes.reduce((sum, wt) => sum + (wt.totalHours || 0), 0);
|
||||
const projectTotalManDays = Math.round((projectTotalHours / 8) * 100) / 100;
|
||||
const projectTotalLaborCost = projectTotalManDays * manDayRate;
|
||||
const projectLoadRate = grandTotalHours > 0 ? ((projectTotalHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="project-subtotal">
|
||||
<td colspan="2"><strong>${projectName} 소계</strong></td>
|
||||
<td><strong>${projectTotalManDays}</strong></td>
|
||||
<td><strong>${projectLoadRate}%</strong></td>
|
||||
<td><strong>₩${projectTotalLaborCost.toLocaleString()}</strong></td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
// 테이블 업데이트
|
||||
tbody.innerHTML = tableRows.join('');
|
||||
|
||||
// 총계 업데이트
|
||||
if (tfoot) {
|
||||
document.getElementById('totalManDays').textContent = grandTotalManDays.toFixed(2);
|
||||
document.getElementById('totalLaborCost').textContent = `₩${grandTotalLaborCost.toLocaleString()}`;
|
||||
tfoot.style.display = 'table-footer-group';
|
||||
}
|
||||
|
||||
console.log('✅ 프로젝트별 분포 테이블 렌더링 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 대체 테이블 렌더링 (작업자 데이터 기반)
|
||||
*/
|
||||
_renderFallbackTable(workerData, tbody, tfoot) {
|
||||
if (!workerData || workerData.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
|
||||
해당 기간에 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (tfoot) tfoot.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const manDayRate = 350000;
|
||||
let totalManDays = 0;
|
||||
let totalLaborCost = 0;
|
||||
|
||||
const tableRows = workerData.map(worker => {
|
||||
const hours = worker.totalHours || 0;
|
||||
const manDays = Math.round((hours / 8) * 100) / 100;
|
||||
const laborCost = manDays * manDayRate;
|
||||
|
||||
totalManDays += manDays;
|
||||
totalLaborCost += laborCost;
|
||||
|
||||
return `
|
||||
<tr class="project-group">
|
||||
<td class="project-name">작업자 기반</td>
|
||||
<td class="work-content">${worker.worker_name}</td>
|
||||
<td class="man-days">${manDays}</td>
|
||||
<td class="load-rate">-</td>
|
||||
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tbody.innerHTML = tableRows.join('');
|
||||
|
||||
// 총계 업데이트
|
||||
if (tfoot) {
|
||||
document.getElementById('totalManDays').textContent = totalManDays.toFixed(2);
|
||||
document.getElementById('totalLaborCost').textContent = `₩${totalLaborCost.toLocaleString()}`;
|
||||
tfoot.style.display = 'table-footer-group';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 오류 분석 테이블 ==========
|
||||
|
||||
/**
|
||||
* 오류 분석 테이블 렌더링
|
||||
* @param {Array} recentWorkData - 최근 작업 데이터
|
||||
*/
|
||||
renderErrorAnalysisTable(recentWorkData) {
|
||||
console.log('📊 오류 분석 테이블 렌더링 시작');
|
||||
console.log('📊 받은 데이터:', recentWorkData);
|
||||
|
||||
const tableBody = document.getElementById('errorAnalysisTableBody');
|
||||
const tableFooter = document.getElementById('errorAnalysisTableFooter');
|
||||
|
||||
console.log('📊 DOM 요소 확인:', { tableBody, tableFooter });
|
||||
|
||||
// DOM 요소 존재 확인
|
||||
if (!tableBody) {
|
||||
console.error('❌ errorAnalysisTableBody 요소를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recentWorkData || recentWorkData.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
|
||||
해당 기간에 오류 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (tableFooter) {
|
||||
tableFooter.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업 형태별 오류 데이터 집계
|
||||
const errorData = this.dataProcessor.aggregateErrorData(recentWorkData);
|
||||
|
||||
let tableRows = [];
|
||||
let grandTotalHours = 0;
|
||||
let grandTotalRegularHours = 0;
|
||||
let grandTotalErrorHours = 0;
|
||||
|
||||
// 프로젝트별로 그룹화
|
||||
const projectGroups = new Map();
|
||||
errorData.forEach(workType => {
|
||||
const projectKey = workType.isVacation ? 'vacation' : workType.project_id;
|
||||
if (!projectGroups.has(projectKey)) {
|
||||
projectGroups.set(projectKey, []);
|
||||
}
|
||||
projectGroups.get(projectKey).push(workType);
|
||||
});
|
||||
|
||||
// 프로젝트별로 렌더링
|
||||
Array.from(projectGroups.entries()).forEach(([projectKey, workTypes]) => {
|
||||
workTypes.forEach((workType, index) => {
|
||||
grandTotalHours += workType.totalHours;
|
||||
grandTotalRegularHours += workType.regularHours;
|
||||
grandTotalErrorHours += workType.errorHours;
|
||||
|
||||
const rowClass = workType.isVacation ? 'vacation-project' : 'project-group';
|
||||
const isFirstWorkType = index === 0;
|
||||
const rowspan = workTypes.length;
|
||||
|
||||
// 세부시간 구성
|
||||
let detailHours = [];
|
||||
if (workType.regularHours > 0) {
|
||||
detailHours.push(`<span class="regular-hours">정규: ${workType.regularHours}h</span>`);
|
||||
}
|
||||
|
||||
// 오류 세부사항 추가
|
||||
workType.errorDetails.forEach(error => {
|
||||
detailHours.push(`<span class="error-hours">오류: ${error.type} ${error.hours}h</span>`);
|
||||
});
|
||||
|
||||
// 작업 타입 구성 (단순화)
|
||||
let workTypeDisplay = '';
|
||||
if (workType.regularHours > 0) {
|
||||
workTypeDisplay += `
|
||||
<div class="work-type-item regular">
|
||||
<span class="work-type-status">정규시간</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
workType.errorDetails.forEach(error => {
|
||||
workTypeDisplay += `
|
||||
<div class="work-type-item error">
|
||||
<span class="work-type-status">오류: ${error.type}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="${rowClass}">
|
||||
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${workType.isVacation ? '연차/휴무' : (workType.project_name || 'N/A')}</td>` : ''}
|
||||
<td class="work-content">${workType.work_type_name}</td>
|
||||
<td class="total-hours">${workType.totalHours}h</td>
|
||||
<td class="detail-hours">
|
||||
${detailHours.join('<br>')}
|
||||
</td>
|
||||
<td class="work-type">
|
||||
<div class="work-type-breakdown">
|
||||
${workTypeDisplay}
|
||||
</div>
|
||||
</td>
|
||||
<td class="error-percentage ${workType.errorHours > 0 ? 'has-error' : ''}">${workType.errorRate}%</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
if (tableRows.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
|
||||
해당 기간에 작업 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (tableFooter) {
|
||||
tableFooter.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
tableBody.innerHTML = tableRows.join('');
|
||||
|
||||
// 총계 업데이트
|
||||
const totalErrorRate = grandTotalHours > 0 ? ((grandTotalErrorHours / grandTotalHours) * 100).toFixed(1) : '0.0';
|
||||
|
||||
// 안전한 DOM 요소 접근
|
||||
const totalErrorHoursElement = document.getElementById('totalErrorHours');
|
||||
if (totalErrorHoursElement) {
|
||||
totalErrorHoursElement.textContent = `${grandTotalHours}h`;
|
||||
}
|
||||
|
||||
if (tableFooter) {
|
||||
const detailHoursCell = tableFooter.querySelector('.total-row td:nth-child(4)');
|
||||
const errorRateCell = tableFooter.querySelector('.total-row td:nth-child(6)');
|
||||
|
||||
if (detailHoursCell) {
|
||||
detailHoursCell.innerHTML = `
|
||||
<strong>정규: ${grandTotalRegularHours}h<br>오류: ${grandTotalErrorHours}h</strong>
|
||||
`;
|
||||
}
|
||||
|
||||
if (errorRateCell) {
|
||||
errorRateCell.innerHTML = `<strong>${totalErrorRate}%</strong>`;
|
||||
}
|
||||
|
||||
tableFooter.style.display = 'table-footer-group';
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ 오류 분석 테이블 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 기간별 작업 현황 테이블 ==========
|
||||
|
||||
/**
|
||||
* 기간별 작업 현황 테이블 렌더링
|
||||
* @param {Array} projectData - 프로젝트 데이터
|
||||
* @param {Array} workerData - 작업자 데이터
|
||||
* @param {Array} recentWorkData - 최근 작업 데이터
|
||||
*/
|
||||
renderWorkStatusTable(projectData, workerData, recentWorkData) {
|
||||
console.log('📈 기간별 작업 현황 테이블 렌더링 시작');
|
||||
|
||||
const tableContainer = document.querySelector('#work-status-tab .table-container');
|
||||
if (!tableContainer) {
|
||||
console.error('❌ 작업 현황 테이블 컨테이너를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터가 없는 경우 처리
|
||||
if (!workerData || workerData.length === 0) {
|
||||
tableContainer.innerHTML = `
|
||||
<div style="text-align: center; padding: 3rem; color: #666;">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
|
||||
<div style="font-size: 1.2rem; margin-bottom: 0.5rem;">데이터가 없습니다</div>
|
||||
<div style="font-size: 0.9rem;">선택한 기간에 작업 데이터가 없습니다.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업자별 데이터 처리
|
||||
const workerStats = this._processWorkerStats(workerData, recentWorkData);
|
||||
|
||||
let tableHTML = `
|
||||
<table class="work-status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>분류(프로젝트)</th>
|
||||
<th>작업내용</th>
|
||||
<th>투입시간</th>
|
||||
<th>작업공수</th>
|
||||
<th>작업일/일평균시간</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
let totalHours = 0;
|
||||
let totalManDays = 0;
|
||||
|
||||
workerStats.forEach(worker => {
|
||||
worker.projects.forEach((project, projectIndex) => {
|
||||
project.workTypes.forEach((workType, workTypeIndex) => {
|
||||
const isFirstProject = projectIndex === 0 && workTypeIndex === 0;
|
||||
const workerRowspan = worker.totalRowspan;
|
||||
|
||||
totalHours += workType.hours;
|
||||
totalManDays += workType.manDays;
|
||||
|
||||
tableHTML += `
|
||||
<tr class="worker-group">
|
||||
${isFirstProject ? `
|
||||
<td class="worker-name" rowspan="${workerRowspan}">${worker.name}</td>
|
||||
` : ''}
|
||||
<td class="project-name">${project.name}</td>
|
||||
<td class="work-content">${workType.name}</td>
|
||||
<td class="work-hours">${workType.hours}h</td>
|
||||
${isFirstProject ? `
|
||||
<td class="man-days" rowspan="${workerRowspan}">${worker.totalManDays.toFixed(1)}</td>
|
||||
<td class="work-days" rowspan="${workerRowspan}">${worker.workDays}일 / ${worker.avgHours.toFixed(1)}h</td>
|
||||
` : ''}
|
||||
<td class="remarks">${workType.remarks}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
tableHTML += `
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="3"><strong>총 공수</strong></td>
|
||||
<td><strong>${totalHours}h</strong></td>
|
||||
<td><strong>${totalManDays.toFixed(1)}</strong></td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
`;
|
||||
|
||||
tableContainer.innerHTML = tableHTML;
|
||||
console.log('✅ 기간별 작업 현황 테이블 렌더링 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자별 통계 처리 (내부 헬퍼)
|
||||
*/
|
||||
_processWorkerStats(workerData, recentWorkData) {
|
||||
if (!workerData || workerData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return workerData.map(worker => {
|
||||
// 해당 작업자의 작업 데이터 필터링
|
||||
const workerWork = recentWorkData ?
|
||||
recentWorkData.filter(work => work.worker_id === worker.worker_id) : [];
|
||||
|
||||
// 프로젝트별로 그룹화
|
||||
const projectMap = new Map();
|
||||
workerWork.forEach(work => {
|
||||
const projectKey = work.project_id || 'unknown';
|
||||
if (!projectMap.has(projectKey)) {
|
||||
projectMap.set(projectKey, {
|
||||
name: work.project_name || `프로젝트 ${projectKey}`,
|
||||
workTypes: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
const project = projectMap.get(projectKey);
|
||||
const workTypeKey = work.work_type_id || 'unknown';
|
||||
const workTypeName = work.work_type_name || `작업유형 ${workTypeKey}`;
|
||||
|
||||
if (!project.workTypes.has(workTypeKey)) {
|
||||
project.workTypes.set(workTypeKey, {
|
||||
name: workTypeName,
|
||||
hours: 0,
|
||||
remarks: '정상'
|
||||
});
|
||||
}
|
||||
|
||||
const workType = project.workTypes.get(workTypeKey);
|
||||
workType.hours += parseFloat(work.work_hours) || 0;
|
||||
|
||||
// 오류가 있으면 비고 업데이트
|
||||
if (work.work_status === 'error' || work.error_type_id) {
|
||||
workType.remarks = work.error_type_name || work.error_description || '오류';
|
||||
}
|
||||
});
|
||||
|
||||
// 프로젝트 배열로 변환
|
||||
const projects = Array.from(projectMap.values()).map(project => ({
|
||||
...project,
|
||||
workTypes: Array.from(project.workTypes.values()).map(wt => ({
|
||||
...wt,
|
||||
manDays: Math.round((wt.hours / 8) * 10) / 10
|
||||
}))
|
||||
}));
|
||||
|
||||
// 전체 행 수 계산
|
||||
const totalRowspan = projects.reduce((sum, p) => sum + p.workTypes.length, 0);
|
||||
|
||||
return {
|
||||
name: worker.worker_name,
|
||||
totalHours: worker.totalHours || 0,
|
||||
totalManDays: (worker.totalHours || 0) / 8,
|
||||
workDays: worker.workingDays || 0,
|
||||
avgHours: worker.avgHours || 0,
|
||||
projects,
|
||||
totalRowspan: Math.max(totalRowspan, 1)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisTableRenderer = new WorkAnalysisTableRenderer();
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
2233
web-ui/pages/analysis/work-analysis-legacy.html
Normal file
2233
web-ui/pages/analysis/work-analysis-legacy.html
Normal file
File diff suppressed because it is too large
Load Diff
363
web-ui/pages/analysis/work-analysis-modular.html
Normal file
363
web-ui/pages/analysis/work-analysis-modular.html
Normal file
@@ -0,0 +1,363 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 분석 | (주)테크니컬코리아</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/work-analysis.css?v=42">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script src="/js/api-config.js?v=1" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="analysis-container">
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header fade-in">
|
||||
<h1 class="page-title">
|
||||
<span class="icon">📊</span>
|
||||
작업 분석
|
||||
</h1>
|
||||
<p class="page-subtitle">기간별/프로젝트별 작업 현황을 분석하고 통계를 확인합니다</p>
|
||||
</header>
|
||||
|
||||
<!-- 분석 모드 탭 -->
|
||||
<nav class="analysis-tabs fade-in">
|
||||
<button class="tab-button active" data-mode="period">
|
||||
📅 기간별 분석
|
||||
</button>
|
||||
<button class="tab-button" data-mode="project">
|
||||
🏗️ 프로젝트별 분석
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 분석 조건 설정 -->
|
||||
<section class="analysis-controls fade-in">
|
||||
<div class="controls-grid">
|
||||
<!-- 기간 설정 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="startDate">
|
||||
<span class="icon">📅</span>
|
||||
시작일
|
||||
</label>
|
||||
<input type="date" id="startDate" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="endDate">
|
||||
<span class="icon">📅</span>
|
||||
종료일
|
||||
</label>
|
||||
<input type="date" id="endDate" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<!-- 기간 확정 버튼 -->
|
||||
<div class="form-group">
|
||||
<button class="confirm-period-button" id="confirmPeriodBtn">
|
||||
<span class="icon">✅</span>
|
||||
기간 확정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 기간 상태 표시 -->
|
||||
<div class="form-group" id="periodStatusGroup" style="display: none;">
|
||||
<div class="period-status">
|
||||
<span class="icon">✅</span>
|
||||
<div>
|
||||
<div style="font-size: 0.8rem; opacity: 0.8; margin-bottom: 2px;">분석 기간</div>
|
||||
<div id="periodStatus">기간이 설정되지 않았습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 분석 결과 영역 -->
|
||||
<main id="analysisResults" class="fade-in">
|
||||
<!-- 로딩 상태 -->
|
||||
<div id="loadingState" class="loading-container" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">분석 중입니다...</p>
|
||||
</div>
|
||||
|
||||
<!-- 분석 탭 네비게이션 -->
|
||||
<div id="analysisTabNavigation" class="tab-navigation" style="display: none;">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-button active" data-tab="work-status">
|
||||
<span class="icon">📈</span>
|
||||
기간별 작업 현황
|
||||
</button>
|
||||
<button class="tab-button" data-tab="project-distribution">
|
||||
<span class="icon">🥧</span>
|
||||
프로젝트별 분포
|
||||
</button>
|
||||
<button class="tab-button" data-tab="worker-performance">
|
||||
<span class="icon">👤</span>
|
||||
작업자별 성과
|
||||
</button>
|
||||
<button class="tab-button" data-tab="error-analysis">
|
||||
<span class="icon">⚠️</span>
|
||||
오류 분석
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 카드 그리드 -->
|
||||
<div id="resultsGrid" class="results-grid" style="display: none;">
|
||||
|
||||
<!-- 통계 카드들 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏰</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">총 작업시간</div>
|
||||
<div class="stat-value" id="totalHours">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">정상 시간</div>
|
||||
<div class="stat-value" id="normalHours">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⚠️</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">오류 시간</div>
|
||||
<div class="stat-value" id="errorHours">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">참여 작업자</div>
|
||||
<div class="stat-value" id="workerCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">오류율</div>
|
||||
<div class="stat-value" id="errorRate">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분석 탭 컨텐츠 -->
|
||||
<div class="tab-contents">
|
||||
|
||||
<!-- 기간별 작업 현황 -->
|
||||
<div id="work-status-tab" class="tab-content active">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">📈</span>
|
||||
기간별 작업 현황
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkStatus()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<!-- 테이블이 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 분포 -->
|
||||
<div id="project-distribution-tab" class="tab-content">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">🥧</span>
|
||||
프로젝트별 분포
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeProjectDistribution()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="production-report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="job-no-header">Job No.</th>
|
||||
<th class="work-content-header">작업내용</th>
|
||||
<th class="man-days-header">공수</th>
|
||||
<th class="load-rate-header">전체 부하율</th>
|
||||
<th class="labor-cost-header">인건비</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectDistributionTableBody">
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
|
||||
분석을 실행해주세요
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot id="projectDistributionTableFooter" style="display: none;">
|
||||
<tr class="total-row">
|
||||
<td colspan="2"><strong>총계</strong></td>
|
||||
<td><strong id="totalManDays">0</strong></td>
|
||||
<td><strong>100%</strong></td>
|
||||
<td><strong id="totalLaborCost">₩0</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자별 성과 -->
|
||||
<div id="worker-performance-tab" class="tab-content">
|
||||
<div class="chart-container chart-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">👤</span>
|
||||
작업자별 성과
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkerPerformance()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<canvas id="workerPerformanceChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오류 분석 -->
|
||||
<div id="error-analysis-tab" class="tab-content">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">⚠️</span>
|
||||
오류 분석
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeErrorAnalysis()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="error-analysis-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job No.</th>
|
||||
<th>작업내용</th>
|
||||
<th>총 시간</th>
|
||||
<th>세부시간</th>
|
||||
<th>작업 타입</th>
|
||||
<th>오류율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errorAnalysisTableBody">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
|
||||
분석을 실행해주세요
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot id="errorAnalysisTableFooter" style="display: none;">
|
||||
<tr class="total-row">
|
||||
<td colspan="2"><strong>총계</strong></td>
|
||||
<td><strong id="totalErrorHours">0h</strong></td>
|
||||
<td><strong>-</strong></td>
|
||||
<td><strong>-</strong></td>
|
||||
<td><strong>0.0%</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 모듈화된 JavaScript 로딩 -->
|
||||
<script src="/js/work-analysis/module-loader.js?v=1" defer></script>
|
||||
|
||||
<script>
|
||||
// 서울 표준시(KST) 기준 날짜 함수들 (하위 호환성 유지)
|
||||
function getKSTDate() {
|
||||
const now = new Date();
|
||||
// UTC 시간에 9시간 추가 (KST = UTC+9)
|
||||
const kstOffset = 9 * 60; // 9시간을 분으로 변환
|
||||
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
|
||||
const kst = new Date(utc + (kstOffset * 60000));
|
||||
return kst;
|
||||
}
|
||||
|
||||
function formatDateToString(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 날짜 문자열을 간단한 형식으로 변환하는 함수 (하위 호환성 유지)
|
||||
function formatSimpleDate(dateStr) {
|
||||
if (!dateStr) return '날짜 없음';
|
||||
if (typeof dateStr === 'string' && dateStr.includes('T')) {
|
||||
return dateStr.split('T')[0]; // 2025-11-01T00:00:00.000Z → 2025-11-01
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트 (하위 호환성 유지)
|
||||
function updateTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
// 시간 표시 요소가 있다면 업데이트
|
||||
const timeElement = document.querySelector('.time-value');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📦 작업 분석 모듈 로딩 시작...');
|
||||
|
||||
// 서울 표준시(KST) 기준 날짜 설정
|
||||
const today = getKSTDate();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
|
||||
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); // 이번 달 마지막 날
|
||||
|
||||
document.getElementById('startDate').value = formatDateToString(monthStart);
|
||||
document.getElementById('endDate').value = formatDateToString(monthEnd);
|
||||
|
||||
// 시간 업데이트 시작
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
});
|
||||
|
||||
// 모듈 로딩 완료 후 초기화
|
||||
window.addEventListener('workAnalysisModulesLoaded', function(event) {
|
||||
console.log('🎉 작업 분석 모듈 로딩 완료:', event.detail.modules);
|
||||
|
||||
// 모듈 로딩 완료 후 추가 초기화 작업이 있다면 여기에 추가
|
||||
});
|
||||
|
||||
// 초기 모드 설정 (하위 호환성 유지)
|
||||
window.currentAnalysisMode = 'period';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
2230
web-ui/pages/analysis/work-analysis.html.backup
Normal file
2230
web-ui/pages/analysis/work-analysis.html.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@
|
||||
<!-- 스크립트 (순서 중요: api-config.js가 먼저 로드되어야 함) -->
|
||||
<script src="/js/api-config.js"></script>
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<script src="/js/modern-dashboard.js" defer></script>
|
||||
<script src="/js/modern-dashboard.js?v=3" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 메인 컨테이너 -->
|
||||
|
||||
Reference in New Issue
Block a user