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:
Hyungi Ahn
2025-11-05 10:12:52 +09:00
parent 052e868599
commit ed40eec261
14 changed files with 8740 additions and 243 deletions

View File

@@ -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;

View File

@@ -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');

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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>
<!-- 메인 컨테이너 -->