Files
TK-FB-Project/web-ui/pages/work/analysis.html
Hyungi Ahn a6ab9e395d refactor: 페이지 구조 대대적 개편 - 명확한 폴더 구조 및 파일명 개선
## 주요 변경사항

### 1. 미사용 페이지 아카이브 (24개)
- admin 폴더 전체 (8개) → .archived-admin/
- 분석 페이지 (5개) → .archived-*
- 공통 페이지 (5개) → .archived-*
- 대시보드 페이지 (2개) → .archived-*
- 기타 (4개) → .archived-*

### 2. 새로운 폴더 구조
```
pages/
├── dashboard.html          (메인 대시보드)
├── work/                   (작업 관련)
│   ├── report-create.html  (작업보고서 작성)
│   ├── report-view.html    (작업보고서 조회)
│   └── analysis.html       (작업 분석)
├── admin/                  (관리 기능)
│   ├── index.html          (관리 메뉴 허브)
│   ├── projects.html       (프로젝트 관리)
│   ├── workers.html        (작업자 관리)
│   ├── codes.html          (코드 관리)
│   └── accounts.html       (계정 관리)
└── profile/                (프로필)
    ├── info.html           (내 정보)
    └── password.html       (비밀번호 변경)
```

### 3. 파일명 개선
- group-leader.html → dashboard.html
- daily-work-report.html → work/report-create.html
- daily-work-report-viewer.html → work/report-view.html
- work-analysis.html → work/analysis.html
- work-management.html → admin/index.html
- project-management.html → admin/projects.html
- worker-management.html → admin/workers.html
- code-management.html → admin/codes.html
- my-profile.html → profile/info.html
- change-password.html → profile/password.html
- admin-settings.html → admin/accounts.html

### 4. 내부 링크 전면 수정
- navbar.html 프로필 메뉴 링크 업데이트
- dashboard.html 빠른 작업 링크 업데이트
- admin/* 페이지 간 링크 업데이트
- load-navbar.js 대시보드 경로 수정

영향받는 파일: 39개

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 10:44:34 +09:00

2899 lines
134 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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=41">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<div class="analysis-container">
<!-- 뒤로가기 버튼 -->
<a href="javascript:history.back()" class="back-button" style="margin: 1rem 0;">
← 뒤로가기
</a>
<!-- 페이지 헤더 -->
<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" onclick="switchAnalysisMode('period')">
📅 기간별 분석
</button>
<button class="tab-button" data-mode="project" onclick="switchAnalysisMode('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" onclick="confirmPeriod()">
<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="periodText">기간이 설정되지 않았습니다</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>
<!-- 탭 컨텐츠 영역 -->
<div id="tabContentContainer" style="display: none;">
<!-- 기간별 작업 현황 탭 -->
<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">
<table class="work-report-table" id="workReportTable">
<thead>
<tr>
<th>작업자<br><small>Worker</small></th>
<th>분류<br><small>Job No.</small></th>
<th>작업내용<br><small>Contents</small></th>
<th>투입시간<br><small>Input</small></th>
<th>작업공수<br><small>Man/Day</small></th>
<th>작업일/<br>일평균시간</th>
<th>비고<br><small>Remarks</small></th>
</tr>
</thead>
<tbody id="workReportTableBody">
<tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: #666;">
분석을 실행하여 데이터를 확인하세요
</td>
</tr>
</tbody>
<tfoot id="workReportTableFooter" style="display: none;">
<tr class="total-row">
<td colspan="3"><strong>총 공수<br><small>Total Man/Day</small></strong></td>
<td><strong id="totalHoursCell">-</strong></td>
<td><strong id="totalManDaysCell">-</strong></td>
<td><strong>-</strong></td>
<td><strong>-</strong></td>
</tr>
</tfoot>
</table>
</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" id="projectDistributionTable">
<thead>
<tr>
<th>Job No.</th>
<th>작업 내용<br><small>Contents</small></th>
<th>공 수<br><small>Man / Day</small></th>
<th>전체 부하율</th>
<th>인건비<br><small>Labor Cost</small></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>합계<br><small>Total</small></strong></td>
<td><strong id="totalManDays">-</strong></td>
<td><strong>100.00%</strong></td>
<td><strong id="totalLaborCost">-</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" class="chart-canvas"></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" id="errorAnalysisTable">
<thead>
<tr>
<th>Job No.</th>
<th>작업내용<br><small>Contents</small></th>
<th>총 시간<br><small>Total Hours</small></th>
<th>세부시간<br><small>Detail Hours</small></th>
<th>작업 타입<br><small>Work Type</small></th>
<th>오류율<br><small>Error Rate</small></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>합계<br><small>Total</small></strong></td>
<td><strong id="totalErrorHours">-</strong></td>
<td><strong>-</strong></td>
<td><strong>-</strong></td>
<td><strong id="totalErrorRate">-</strong></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
<!-- 상세 데이터 테이블 -->
<div id="dataTableContainer" class="data-table-container" style="display: none;">
<div class="table-header">
<h3 class="table-title">
<span class="icon">📋</span>
상세 작업 데이터
</h3>
</div>
<div style="overflow-x: auto;">
<table class="data-table" id="detailDataTable">
<thead>
<tr>
<th>날짜</th>
<th>작업자</th>
<th>프로젝트</th>
<th>작업 유형</th>
<th>작업 시간</th>
<th>상태</th>
<th>오류 유형</th>
</tr>
</thead>
<tbody id="detailDataBody">
<!-- 동적으로 생성됨 -->
</tbody>
</table>
</div>
</div>
</main>
</div>
<!-- JavaScript -->
<script type="module" src="/js/work-analysis.js?v=5"></script>
<script>
// 날짜 및 시간 함수들은 WorkAnalysis.utils 네임스페이스로 이동됨
// ========== API 클라이언트 ==========
const WorkAnalysisAPI = {
// 캐시 저장소
cache: new Map(),
// 캐시 만료 시간 (5분)
CACHE_DURATION: 5 * 60 * 1000,
// 기본 API 호출 함수
async call(endpoint, method = 'GET', data = null, useCache = true) {
const cacheKey = `${method}:${endpoint}`;
// 캐시 확인
if (useCache && this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey);
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
console.log('📦 캐시에서 데이터 반환:', endpoint);
return cached.data;
}
}
try {
console.log('🌐 API 호출:', endpoint);
const response = await window.apiCall(endpoint, method, data);
// 성공 시 캐시 저장
if (useCache && response.success) {
this.cache.set(cacheKey, {
data: response,
timestamp: Date.now()
});
}
return response;
} catch (error) {
console.error('❌ API 호출 실패:', endpoint, error);
throw error;
}
},
// 배치 API 호출 (병렬 처리)
async batchCall(requests) {
console.log('🔄 배치 API 호출 시작:', requests.length, '개');
const promises = requests.map(async (req) => {
try {
const result = await this.call(req.endpoint, req.method, req.data, req.useCache);
return { name: req.name, success: true, data: result };
} catch (error) {
console.warn(`⚠️ ${req.name} API 실패:`, error);
return { name: req.name, success: false, error: error.message, data: null };
}
});
const results = await Promise.all(promises);
console.log('✅ 배치 API 호출 완료');
return results;
},
// 캐시 초기화
clearCache() {
this.cache.clear();
console.log('🗑️ API 캐시 초기화');
}
};
// ========== 에러 처리 시스템 ==========
const ErrorHandler = {
// 에러 타입 정의
ErrorTypes: {
NETWORK: 'network',
DATA: 'data',
VALIDATION: 'validation',
PERMISSION: 'permission',
UNKNOWN: 'unknown'
},
// 사용자 친화적 메시지 매핑
UserMessages: {
network: {
title: '연결 오류',
message: '서버와의 연결에 문제가 있습니다. 잠시 후 다시 시도해주세요.',
action: '다시 시도'
},
data: {
title: '데이터 오류',
message: '요청하신 데이터를 불러올 수 없습니다. 다른 기간을 선택해보세요.',
action: '기간 변경'
},
validation: {
title: '입력 오류',
message: '입력하신 정보를 확인해주세요.',
action: '다시 입력'
},
permission: {
title: '권한 오류',
message: '이 기능을 사용할 권한이 없습니다. 관리자에게 문의하세요.',
action: '문의하기'
},
unknown: {
title: '알 수 없는 오류',
message: '예상치 못한 오류가 발생했습니다. 관리자에게 문의하세요.',
action: '새로고침'
}
},
// 에러 분류 함수
classifyError(error) {
if (!error) return this.ErrorTypes.UNKNOWN;
const message = error.message || error.toString().toLowerCase();
if (message.includes('network') || message.includes('fetch') || message.includes('연결')) {
return this.ErrorTypes.NETWORK;
} else if (message.includes('data') || message.includes('데이터') || message.includes('not found')) {
return this.ErrorTypes.DATA;
} else if (message.includes('validation') || message.includes('invalid') || message.includes('유효하지')) {
return this.ErrorTypes.VALIDATION;
} else if (message.includes('permission') || message.includes('unauthorized') || message.includes('권한')) {
return this.ErrorTypes.PERMISSION;
} else {
return this.ErrorTypes.UNKNOWN;
}
},
// 토스트 알림 표시
showToast(message, type = 'error', duration = 5000) {
// 기존 토스트 제거
const existingToast = document.querySelector('.error-toast');
if (existingToast) {
existingToast.remove();
}
// 토스트 생성
const toast = document.createElement('div');
toast.className = `error-toast toast-${type}`;
toast.innerHTML = `
<div class="toast-content">
<span class="toast-icon">${type === 'error' ? '⚠️' : type === 'success' ? '✅' : ''}</span>
<span class="toast-message">${message}</span>
<button class="toast-close" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
// 스타일 적용
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'error' ? '#ff4757' : type === 'success' ? '#2ed573' : '#3742fa'};
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
max-width: 400px;
animation: slideIn 0.3s ease-out;
`;
document.body.appendChild(toast);
// 자동 제거
setTimeout(() => {
if (toast.parentNode) {
toast.style.animation = 'slideOut 0.3s ease-in';
setTimeout(() => toast.remove(), 300);
}
}, duration);
},
// 통합 에러 처리 함수
handle(error, context = '', showUserMessage = true) {
const errorType = this.classifyError(error);
const userMessage = this.UserMessages[errorType];
// 콘솔에 상세 로그
console.error(`❌ [${context}] ${errorType.toUpperCase()} 오류:`, error);
// 사용자에게 친화적 메시지 표시
if (showUserMessage) {
this.showToast(`${userMessage.title}: ${userMessage.message}`, 'error');
}
return {
type: errorType,
userMessage: userMessage,
originalError: error
};
},
// 복구 제안 함수
suggestRecovery(errorType, context) {
const suggestions = {
network: ['인터넷 연결을 확인하세요', '잠시 후 다시 시도하세요', '페이지를 새로고침하세요'],
data: ['다른 기간을 선택해보세요', '필터 조건을 변경해보세요', '관리자에게 문의하세요'],
validation: ['입력 정보를 다시 확인하세요', '필수 항목을 모두 입력하세요'],
permission: ['로그인 상태를 확인하세요', '관리자에게 권한을 요청하세요'],
unknown: ['페이지를 새로고침하세요', '브라우저 캐시를 삭제하세요', '관리자에게 문의하세요']
};
return suggestions[errorType] || suggestions.unknown;
}
};
// 성능 최적화 CSS 및 애니메이션 추가
const style = document.createElement('style');
style.textContent = `
/* 토스트 애니메이션 */
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* 페이드인 애니메이션 */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 로딩 프로그레스 바 */
.loading-progress {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, #3742fa, #2ed573);
transition: width 0.3s ease;
border-radius: 0 2px 2px 0;
}
/* 로딩 상태 개선 */
#loadingState {
position: relative;
overflow: hidden;
}
/* 성능 최적화: GPU 가속 */
.fade-in, .chart-container, .table-container {
will-change: transform, opacity;
transform: translateZ(0);
}
/* 토스트 스타일 */
.toast-content {
display: flex;
align-items: center;
gap: 10px;
}
.toast-close {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
margin-left: auto;
transition: opacity 0.2s ease;
}
.toast-close:hover {
opacity: 0.7;
}
/* 스크롤 성능 최적화 */
.table-container {
contain: layout style paint;
}
/* 반응형 최적화 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
`;
document.head.appendChild(style);
// ========== 작업 분석 네임스페이스 ==========
const WorkAnalysis = {
// ========== 유틸리티 함수들 ==========
utils: {
// 날짜 관련
getKSTDate: function() {
const now = new Date();
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
const kst = new Date(utc + (9 * 3600000));
return kst;
},
formatDateToString: function(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}`;
},
formatSimpleDate: function(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;
},
// 비즈니스 로직 관련
isWeekend: function(dateStr) {
const date = new Date(dateStr);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // 0: 일요일, 6: 토요일
},
isVacationProject: function(projectName) {
if (!projectName) return false;
const vacationKeywords = ['연차', '휴무', '휴가', '병가', '특별휴가'];
return vacationKeywords.some(keyword => projectName.includes(keyword));
}
},
// ========== UI 제어 함수들 ==========
ui: {
// 로딩 상태 관리
loadingStates: new Map(),
showLoading: function(context = 'default', message = '분석 중입니다...') {
const elements = {
emptyState: document.getElementById('emptyState'),
resultsGrid: document.getElementById('resultsGrid'),
chartsContainer: document.getElementById('chartsContainer'),
dataTableContainer: document.getElementById('dataTableContainer'),
loadingState: document.getElementById('loadingState')
};
// 로딩 상태 추적
this.loadingStates.set(context, true);
// 모든 요소 숨기기 (성능 최적화: 한 번에 처리)
const hideElements = [elements.emptyState, elements.resultsGrid, elements.chartsContainer, elements.dataTableContainer];
hideElements.forEach(el => {
if (el && el.style.display !== 'none') {
el.style.display = 'none';
}
});
// 로딩 상태 표시 및 메시지 업데이트
if (elements.loadingState) {
elements.loadingState.style.display = 'flex';
// 로딩 메시지 업데이트
const loadingText = elements.loadingState.querySelector('.loading-text');
if (loadingText) {
loadingText.textContent = message;
}
// 프로그레스 바 애니메이션 시작
this.startProgressAnimation();
}
},
showResults: function(context = 'default') {
// 로딩 상태 해제
this.loadingStates.set(context, false);
const elements = {
loadingState: document.getElementById('loadingState'),
emptyState: document.getElementById('emptyState'),
resultsGrid: document.getElementById('resultsGrid'),
chartsContainer: document.getElementById('chartsContainer'),
dataTableContainer: document.getElementById('dataTableContainer')
};
// 프로그레스 애니메이션 중지
this.stopProgressAnimation();
// 부드러운 전환 효과
if (elements.loadingState) {
elements.loadingState.style.opacity = '0';
setTimeout(() => {
elements.loadingState.style.display = 'none';
elements.loadingState.style.opacity = '1';
}, 200);
}
if (elements.emptyState) elements.emptyState.style.display = 'none';
// 결과 표시 (성능 최적화: 배치 DOM 업데이트)
requestAnimationFrame(() => {
const showElements = [
{ el: elements.resultsGrid, display: 'grid' },
{ el: elements.chartsContainer, display: 'block' },
{ el: elements.dataTableContainer, display: 'block' }
];
showElements.forEach(({ el, display }) => {
if (el && el.style.display !== display) {
el.style.display = display;
el.style.opacity = '0';
el.style.animation = 'fadeIn 0.3s ease-out forwards';
}
});
});
},
showEmpty: function() {
const elements = {
loadingState: document.getElementById('loadingState'),
resultsGrid: document.getElementById('resultsGrid'),
chartsContainer: document.getElementById('chartsContainer'),
dataTableContainer: document.getElementById('dataTableContainer'),
emptyState: document.getElementById('emptyState')
};
// 모든 요소 숨기기
Object.values(elements).forEach(el => {
if (el && el !== elements.emptyState) el.style.display = 'none';
});
// 빈 상태만 표시
if (elements.emptyState) {
elements.emptyState.style.display = 'flex';
}
},
updateTime: function() {
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;
}
},
// 프로그레스 애니메이션 관리
progressInterval: null,
progressValue: 0,
startProgressAnimation: function() {
this.progressValue = 0;
const progressBar = document.querySelector('.loading-progress');
if (progressBar) {
this.progressInterval = setInterval(() => {
this.progressValue += Math.random() * 15;
if (this.progressValue > 90) this.progressValue = 90;
progressBar.style.width = this.progressValue + '%';
}, 200);
}
},
stopProgressAnimation: function() {
if (this.progressInterval) {
clearInterval(this.progressInterval);
this.progressInterval = null;
}
const progressBar = document.querySelector('.loading-progress');
if (progressBar) {
progressBar.style.width = '100%';
setTimeout(() => {
progressBar.style.width = '0%';
}, 300);
}
},
// 디바운스 함수 (성능 최적화)
debounce: function(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// 스로틀 함수 (성능 최적화)
throttle: function(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
},
// ========== 분석 함수들 ==========
analysis: {
// 기간별 작업 현황 분석
async workStatus() {
if (!isAnalysisEnabled) {
ErrorHandler.showToast('먼저 기간을 확정해주세요.', 'info');
return;
}
console.log('📊 기간별 작업 현황 분석 시작');
try {
WorkAnalysis.ui.showLoading('workStatus', '기간별 작업 현황을 분석하고 있습니다...');
// 성능 측정 시작
const startTime = performance.now();
// 기존 renderTimeSeriesChart 로직 사용
await renderTimeSeriesChart(confirmedStartDate, confirmedEndDate, '');
// 성능 측정 종료
const endTime = performance.now();
console.log(`⚡ 기간별 작업 현황 분석 소요시간: ${(endTime - startTime).toFixed(2)}ms`);
WorkAnalysis.ui.showResults('workStatus');
ErrorHandler.showToast('기간별 작업 현황 분석이 완료되었습니다.', 'success');
console.log('✅ 기간별 작업 현황 분석 완료');
} catch (error) {
WorkAnalysis.ui.showResults('workStatus'); // 로딩 상태 해제
ErrorHandler.handle(error, '기간별 작업 현황 분석');
// 복구 제안
const suggestions = ErrorHandler.suggestRecovery(ErrorHandler.classifyError(error), 'workStatus');
console.log('💡 복구 제안:', suggestions);
}
},
// 프로젝트별 분포 분석
async projectDistribution() {
if (!isAnalysisEnabled) {
ErrorHandler.showToast('먼저 기간을 확정해주세요.', 'info');
return;
}
console.log('📋 프로젝트별 작업 분포 분석 시작');
try {
WorkAnalysis.ui.showLoading();
const params = new URLSearchParams({
start: confirmedStartDate,
end: confirmedEndDate,
limit: 2000
});
const response = await WorkAnalysisAPI.call(`/work-analysis/recent-work?${params}`, 'GET', null, true);
if (response.success && response.data) {
renderProjectDistributionTableFromRecentWork(response.data, []);
WorkAnalysis.ui.showResults();
ErrorHandler.showToast('프로젝트별 분포 분석이 완료되었습니다.', 'success');
console.log('✅ 프로젝트별 작업 분포 분석 완료 (캐싱)');
} else {
WorkAnalysis.ui.showEmpty();
ErrorHandler.showToast('선택한 기간에 프로젝트 데이터가 없습니다. 다른 기간을 선택해보세요.', 'info');
}
} catch (error) {
WorkAnalysis.ui.showResults();
ErrorHandler.handle(error, '프로젝트별 분포 분석');
// 데이터 없음 상태 표시
WorkAnalysis.ui.showEmpty();
}
},
// 작업자별 성과 분석
async workerPerformance() {
if (!isAnalysisEnabled) {
ErrorHandler.showToast('먼저 기간을 확정해주세요.', 'info');
return;
}
console.log('👤 작업자별 성과 분석 시작');
try {
WorkAnalysis.ui.showLoading();
const params = new URLSearchParams({
start: confirmedStartDate,
end: confirmedEndDate
});
const response = await WorkAnalysisAPI.call(`/work-analysis/worker-stats?${params}`, 'GET', null, true);
if (response.success && response.data) {
renderWorkerPerformanceChart(response.data);
WorkAnalysis.ui.showResults();
ErrorHandler.showToast('작업자별 성과 분석이 완료되었습니다.', 'success');
console.log('✅ 작업자별 성과 분석 완료 (캐싱)');
} else {
WorkAnalysis.ui.showEmpty();
ErrorHandler.showToast('선택한 기간에 작업자 데이터가 없습니다.', 'info');
}
} catch (error) {
WorkAnalysis.ui.showResults();
ErrorHandler.handle(error, '작업자별 성과 분석');
WorkAnalysis.ui.showEmpty();
}
},
// 오류 분석
async errorAnalysis() {
console.log('⚠️ 오류 분석 시작');
if (!confirmedStartDate || !confirmedEndDate) {
ErrorHandler.showToast('먼저 기간을 확정해주세요.', 'info');
return;
}
try {
WorkAnalysis.ui.showLoading();
const batchRequests = [
{
name: 'recentWork',
endpoint: `/work-analysis/recent-work?start=${confirmedStartDate}&end=${confirmedEndDate}&limit=2000`,
method: 'GET',
useCache: true
},
{
name: 'errorAnalysis',
endpoint: `/work-analysis/error-analysis?start=${confirmedStartDate}&end=${confirmedEndDate}`,
method: 'GET',
useCache: true
}
];
const results = await WorkAnalysisAPI.batchCall(batchRequests);
const recentWorkResponse = results.find(r => r.name === 'recentWork');
if (recentWorkResponse && recentWorkResponse.success && recentWorkResponse.data.success) {
renderErrorAnalysisTable(recentWorkResponse.data.data);
WorkAnalysis.ui.showResults();
ErrorHandler.showToast('오류 분석이 완료되었습니다.', 'success');
console.log('✅ 오류 분석 완료 (배치)');
} else {
WorkAnalysis.ui.showEmpty();
ErrorHandler.showToast('선택한 기간에 오류 데이터가 없습니다.', 'info');
}
} catch (error) {
WorkAnalysis.ui.showResults();
ErrorHandler.handle(error, '오류 분석');
WorkAnalysis.ui.showEmpty();
}
}
},
// ========== 렌더링 함수들 ==========
render: {
// 결과 카드 업데이트
updateResultCards: function(data) {
const elements = {
totalHours: document.getElementById('totalHours'),
normalHours: document.getElementById('normalHours'),
errorHours: document.getElementById('errorHours'),
workerCount: document.getElementById('workerCount'),
errorRate: document.getElementById('errorRate')
};
if (elements.totalHours) elements.totalHours.textContent = data.totalHours || 0;
if (elements.normalHours) elements.normalHours.textContent = data.normalHours || 0;
if (elements.errorHours) elements.errorHours.textContent = data.errorHours || 0;
if (elements.workerCount) elements.workerCount.textContent = data.workerCount || 0;
if (elements.errorRate) elements.errorRate.textContent = (data.errorRate || 0) + '%';
// 프로그레스 바 업데이트
const totalPercent = Math.min((data.totalHours / 1000) * 100, 100);
const errorPercent = data.totalHours > 0 ? (data.errorHours / data.totalHours) * 100 : 0;
const progressElements = {
normalHours: document.getElementById('normalHoursProgress'),
errorHours: document.getElementById('errorHoursProgress'),
totalHours: document.getElementById('totalHoursProgress'),
worker: document.getElementById('workerProgress')
};
if (progressElements.normalHours) progressElements.normalHours.style.width = (100 - errorPercent) + '%';
if (progressElements.errorHours) progressElements.errorHours.style.width = errorPercent + '%';
if (progressElements.totalHours) progressElements.totalHours.style.width = '85%';
if (progressElements.worker) progressElements.worker.style.width = '75%';
}
}
};
// ========== 공통 유틸리티 함수들 (하위 호환성) ==========
// ========== 하위 호환성을 위한 글로벌 함수들 ==========
// 네임스페이스 함수들을 글로벌로 노출 (하위 호환성)
function isWeekend(dateStr) {
return WorkAnalysis.utils.isWeekend(dateStr);
}
function isVacationProject(projectName) {
return WorkAnalysis.utils.isVacationProject(projectName);
}
function getKSTDate() {
return WorkAnalysis.utils.getKSTDate();
}
function formatDateToString(date) {
return WorkAnalysis.utils.formatDateToString(date);
}
function formatSimpleDate(dateStr) {
return WorkAnalysis.utils.formatSimpleDate(dateStr);
}
function updateTime() {
return WorkAnalysis.ui.updateTime();
}
function showLoading() {
return WorkAnalysis.ui.showLoading();
}
function showResults() {
return WorkAnalysis.ui.showResults();
}
function showEmpty() {
return WorkAnalysis.ui.showEmpty();
}
// 분석 함수들을 네임스페이스로 연결 (하위 호환성)
async function analyzeWorkStatus() {
return await WorkAnalysis.analysis.workStatus();
}
async function analyzeProjectDistribution() {
return await WorkAnalysis.analysis.projectDistribution();
}
async function analyzeWorkerPerformance() {
return await WorkAnalysis.analysis.workerPerformance();
}
async function analyzeErrorAnalysis() {
return await WorkAnalysis.analysis.errorAnalysis();
}
// 렌더링 함수들을 네임스페이스로 연결 (하위 호환성)
function updateResultCards(data) {
return WorkAnalysis.render.updateResultCards(data);
}
// ========== 전역 변수 ==========
let confirmedStartDate = null;
let confirmedEndDate = null;
let isAnalysisEnabled = false;
// 성능 모니터링 및 정리 시스템
const PerformanceMonitor = {
timers: new Set(),
intervals: new Set(),
observers: new Set(),
// 타이머 등록 및 관리
addTimer: function(timerId) {
this.timers.add(timerId);
},
addInterval: function(intervalId) {
this.intervals.add(intervalId);
},
// 메모리 정리
cleanup: function() {
// 모든 타이머 정리
this.timers.forEach(id => clearTimeout(id));
this.intervals.forEach(id => clearInterval(id));
this.observers.forEach(observer => observer.disconnect());
// API 캐시 정리
if (WorkAnalysisAPI) {
WorkAnalysisAPI.clearCache();
}
// UI 상태 정리
if (WorkAnalysis && WorkAnalysis.ui) {
WorkAnalysis.ui.loadingStates.clear();
if (WorkAnalysis.ui.progressInterval) {
clearInterval(WorkAnalysis.ui.progressInterval);
}
}
console.log('🧹 메모리 정리 완료');
}
};
// 페이지 로드 시 초기화
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);
// 시간 업데이트 시작 (성능 최적화: 스로틀링 적용)
const throttledUpdateTime = WorkAnalysis.ui.throttle(updateTime, 1000);
throttledUpdateTime();
const timeInterval = setInterval(throttledUpdateTime, 1000);
PerformanceMonitor.addInterval(timeInterval);
console.log('✅ 작업 분석 페이지 초기화 완료');
});
// 페이지 언로드 시 정리
window.addEventListener('beforeunload', function() {
PerformanceMonitor.cleanup();
});
// 메모리 사용량 모니터링 (개발 환경에서만)
if (window.location.hostname === 'localhost') {
const memoryInterval = setInterval(() => {
if (performance.memory) {
const memory = performance.memory;
const used = Math.round(memory.usedJSHeapSize / 1048576);
const total = Math.round(memory.totalJSHeapSize / 1048576);
if (used > 50) { // 50MB 이상 사용 시 경고
console.warn(`⚠️ 메모리 사용량: ${used}MB / ${total}MB`);
}
}
}, 30000); // 30초마다 체크
PerformanceMonitor.addInterval(memoryInterval);
}
// 기간 확정 함수
function confirmPeriod() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
if (!startDate || !endDate) {
ErrorHandler.showToast('시작일과 종료일을 모두 선택해주세요.', 'info');
return;
}
if (new Date(startDate) > new Date(endDate)) {
ErrorHandler.showToast('시작일이 종료일보다 늦을 수 없습니다.', 'info');
return;
}
confirmedStartDate = startDate;
confirmedEndDate = endDate;
isAnalysisEnabled = true;
// UI 업데이트
const periodStatusGroup = document.getElementById('periodStatusGroup');
const periodText = document.getElementById('periodText');
periodStatusGroup.style.display = 'block';
periodText.textContent = `${startDate} ~ ${endDate}`;
// 모든 분석 버튼 활성화
document.querySelectorAll('.chart-analyze-btn').forEach(btn => {
btn.disabled = false;
});
// 탭 네비게이션과 컨텐츠 표시
document.getElementById('analysisTabNavigation').style.display = 'block';
document.getElementById('tabContentContainer').style.display = 'block';
console.log(`✅ 기간 확정: ${startDate} ~ ${endDate}`);
}
// 탭 전환 함수
function switchTab(tabId) {
// 모든 탭 버튼 비활성화
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
// 모든 탭 컨텐츠 숨기기
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
// 선택된 탭 버튼 활성화
document.querySelector(`[data-tab="${tabId}"]`).classList.add('active');
// 선택된 탭 컨텐츠 표시
document.getElementById(`${tabId}-tab`).classList.add('active');
console.log(`🔄 탭 전환: ${tabId}`);
}
// 탭 버튼 이벤트 리스너 추가
document.addEventListener('DOMContentLoaded', function() {
// 기존 초기화 코드...
// 탭 버튼 클릭 이벤트
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', function() {
const tabId = this.getAttribute('data-tab');
switchTab(tabId);
});
});
});
// 개별 분석 함수들은 WorkAnalysis.analysis 네임스페이스로 이동됨
async function analyzeProjectDistribution() {
if (!isAnalysisEnabled) {
alert('먼저 기간을 확정해주세요.');
return;
}
console.log('📋 프로젝트별 작업 분포 분석 시작');
try {
const params = new URLSearchParams({
start: confirmedStartDate,
end: confirmedEndDate,
limit: 2000
});
// 기간별 작업 현황 API를 사용해서 프로젝트별 데이터 가져오기
const [projectWorktypeResponse, workerResponse, recentWorkResponse] = await Promise.all([
apiCall(`/work-analysis/project-worktype-analysis?${params}`, 'GET'),
apiCall(`/work-analysis/worker-stats?${params}`, 'GET'),
apiCall(`/work-analysis/recent-work?${params}`, 'GET')
]);
console.log('🔍 프로젝트-작업유형 API 응답:', projectWorktypeResponse);
console.log('🔍 작업자 API 응답:', workerResponse);
console.log('🔍 최근 작업 API 응답:', recentWorkResponse);
// recent-work 데이터로 프로젝트별 취합
const projectData = aggregateProjectData(recentWorkResponse.data || []);
console.log('📊 취합된 프로젝트 데이터:', projectData);
renderProjectDistributionTableFromRecentWork(projectData, workerResponse.data);
console.log('✅ 프로젝트별 작업 분포 분석 완료');
} catch (error) {
console.error('❌ 프로젝트별 작업 분포 분석 오류:', error);
alert('프로젝트별 작업 분포 분석에 실패했습니다.');
}
}
async function analyzeWorkerPerformance() {
if (!isAnalysisEnabled) {
alert('먼저 기간을 확정해주세요.');
return;
}
console.log('👤 작업자별 성과 분석 시작');
try {
const params = new URLSearchParams({
start: confirmedStartDate,
end: confirmedEndDate
});
// 최적화된 API 호출 (캐싱 사용)
const response = await WorkAnalysisAPI.call(`/work-analysis/worker-stats?${params}`, 'GET', null, true);
if (response.success && response.data) {
renderWorkerPerformanceChart(response.data);
console.log('✅ 작업자별 성과 분석 완료 (캐싱)');
} else {
console.warn('⚠️ 작업자 데이터가 없습니다');
alert('작업자 데이터를 불러올 수 없습니다.');
}
} catch (error) {
console.error('❌ 작업자별 성과 분석 오류:', error);
alert('작업자별 성과 분석에 실패했습니다.');
}
}
// 오류 분석 함수
async function analyzeErrorAnalysis() {
console.log('⚠️ 오류 분석 시작');
if (!confirmedStartDate || !confirmedEndDate) {
alert('기간을 먼저 확정해주세요.');
return;
}
try {
// 배치 API 호출로 최적화
const batchRequests = [
{
name: 'recentWork',
endpoint: `/work-analysis/recent-work?start=${confirmedStartDate}&end=${confirmedEndDate}&limit=2000`,
method: 'GET',
useCache: true
},
{
name: 'errorAnalysis',
endpoint: `/work-analysis/error-analysis?start=${confirmedStartDate}&end=${confirmedEndDate}`,
method: 'GET',
useCache: true
}
];
const results = await WorkAnalysisAPI.batchCall(batchRequests);
const recentWorkResponse = results.find(r => r.name === 'recentWork');
const errorAnalysisResponse = results.find(r => r.name === 'errorAnalysis');
console.log('🔍 최근 작업 API 응답 (배치):', recentWorkResponse);
console.log('🔍 오류 분석 API 응답 (배치):', errorAnalysisResponse);
if (recentWorkResponse && recentWorkResponse.success && recentWorkResponse.data.success) {
renderErrorAnalysisTable(recentWorkResponse.data.data);
console.log('✅ 오류 분석 완료');
} else {
throw new Error(recentWorkResponse.message || '오류 분석 실패');
}
} catch (error) {
console.error('❌ 오류 분석 실패:', error);
alert('오류 분석에 실패했습니다.');
}
}
// 오류 분석 테이블 렌더링
function 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 = 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';
}
}
}
// 헬퍼 함수들 (공통 함수 사용)
// 작업 형태별 오류 데이터 집계 함수
function aggregateErrorData(recentWorkData) {
const workTypeMap = new Map();
let vacationData = null; // 연차/휴무 통합 데이터
recentWorkData.forEach(work => {
const isWeekendDay = isWeekend(work.report_date);
const isVacation = isVacationProject(work.project_name);
// 주말 연차는 완전히 제외
if (isWeekendDay && 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
};
}
const hours = parseFloat(work.work_hours) || 0;
if (work.work_status === 'error' || work.error_type_id) {
vacationData.errorHours += hours;
const errorTypeName = work.error_type_name || work.error_description || '설계미스';
if (!vacationData.errorDetails.has(errorTypeName)) {
vacationData.errorDetails.set(errorTypeName, 0);
}
vacationData.errorDetails.set(errorTypeName,
vacationData.errorDetails.get(errorTypeName) + hours
);
} else {
vacationData.regularHours += hours;
}
} else {
// 일반 프로젝트 처리
const workTypeKey = work.work_type_id || 'unknown';
const projectName = work.project_name || `프로젝트 ${work.project_id}`;
const workTypeName = work.work_type_name || `작업유형 ${workTypeKey}`;
// 작업 형태별로 집계 (프로젝트별로 구분)
const combinedKey = `${work.project_id || 'unknown'}_${workTypeKey}`;
if (!workTypeMap.has(combinedKey)) {
workTypeMap.set(combinedKey, {
project_id: work.project_id,
project_name: projectName,
job_no: work.job_no,
work_type_id: workTypeKey,
work_type_name: workTypeName,
regularHours: 0,
errorHours: 0,
errorDetails: new Map(), // 오류 유형별 세분화
isVacation: false
});
}
const workTypeData = workTypeMap.get(combinedKey);
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;
}
}
});
// 결과 배열 생성
const result = Array.from(workTypeMap.values());
// 연차/휴무 데이터가 있으면 추가
if (vacationData && (vacationData.regularHours > 0 || vacationData.errorHours > 0)) {
result.push(vacationData);
}
// 최종 데이터 처리
return 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 || '');
});
}
// 프로젝트별 작업 분포 테이블 렌더링
function renderProjectDistributionTable(projectData, workerData) {
const tbody = document.getElementById('projectDistributionTableBody');
const tfoot = document.getElementById('projectDistributionTableFooter');
if (!tbody) return;
console.log('📋 프로젝트별 분포 테이블 렌더링:', projectData);
console.log('📋 작업자 데이터:', workerData);
// 프로젝트 API 데이터가 null이면 작업자 데이터로 대체
if (!projectData || !projectData.projects || projectData.projects.length === 0 ||
(projectData.projects[0] && projectData.projects[0].total_project_hours === null)) {
console.log('⚠️ 프로젝트 API 데이터가 없어서 작업자 데이터로 대체합니다.');
renderFallbackTable(workerData);
return;
}
let tableRows = [];
let grandTotalHours = 0;
let grandTotalManDays = 0;
let grandTotalLaborCost = 0;
// 공수당 인건비 (350,000원)
const manDayRate = 350000;
// 먼저 전체 시간을 계산 (부하율 계산용)
if (projectData && projectData.projects && Array.isArray(projectData.projects)) {
projectData.projects.forEach(project => {
const workTypes = project.work_types || [];
workTypes.forEach(workType => {
grandTotalHours += parseFloat(workType.total_hours) || 0;
});
});
}
if (projectData && projectData.projects && Array.isArray(projectData.projects)) {
projectData.projects.forEach(project => {
const projectName = project.project_name || '알 수 없는 프로젝트';
const workTypes = project.work_types || [];
let projectTotalHours = 0;
let projectTotalManDays = 0;
let projectTotalLaborCost = 0;
if (workTypes.length === 0) {
// 작업유형이 없는 경우
const projectHours = parseFloat(project.total_hours) || 0;
const manDays = Math.round((projectHours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
const loadRate = grandTotalHours > 0 ? ((projectHours / grandTotalHours) * 100).toFixed(2) : '0.00';
projectTotalHours += projectHours;
projectTotalManDays += manDays;
projectTotalLaborCost += laborCost;
tableRows.push(`
<tr class="project-group">
<td class="project-name">${projectName}</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 workTypeName = workType.work_type_name || `작업유형 ${workType.work_type_id}`;
const workTypeHours = parseFloat(workType.total_hours) || 0;
const manDays = Math.round((workTypeHours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
const loadRate = grandTotalHours > 0 ? ((workTypeHours / grandTotalHours) * 100).toFixed(2) : '0.00';
projectTotalHours += workTypeHours;
projectTotalManDays += manDays;
projectTotalLaborCost += laborCost;
const isFirstWorkType = index === 0;
const rowspan = workTypes.length + 1; // +1 for subtotal row
tableRows.push(`
<tr class="project-group">
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${projectName}</td>` : ''}
<td class="work-content">${workTypeName}</td>
<td class="man-days">${manDays}</td>
<td class="load-rate">${loadRate}%</td>
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
</tr>
`);
});
// 프로젝트별 합계 행 추가
const projectLoadRate = grandTotalHours > 0 ? ((projectTotalHours / grandTotalHours) * 100).toFixed(2) : '0.00';
tableRows.push(`
<tr class="project-subtotal">
<td class="work-content"><strong>합계</strong></td>
<td class="man-days"><strong>${Math.round(projectTotalManDays * 100) / 100}</strong></td>
<td class="load-rate"><strong>${projectLoadRate}%</strong></td>
<td class="labor-cost"><strong>₩${projectTotalLaborCost.toLocaleString()}</strong></td>
</tr>
`);
}
grandTotalManDays += projectTotalManDays;
grandTotalLaborCost += projectTotalLaborCost;
});
}
if (tableRows.length > 0) {
tbody.innerHTML = tableRows.join('');
// 전체 합계 행 업데이트
document.getElementById('totalManDays').textContent = `${Math.round(grandTotalManDays * 100) / 100} 공수`;
document.getElementById('totalLaborCost').textContent = `${grandTotalLaborCost.toLocaleString()}`;
if (tfoot) {
tfoot.style.display = 'table-footer-group';
}
} else {
tbody.innerHTML = `
<tr>
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
해당 기간에 프로젝트 데이터가 없습니다
</td>
</tr>
`;
if (tfoot) {
tfoot.style.display = 'none';
}
}
}
// 작업자 데이터로 대체 테이블 렌더링
function renderFallbackTable(workerData) {
const tbody = document.getElementById('projectDistributionTableBody');
const tfoot = document.getElementById('projectDistributionTableFooter');
if (!workerData || !Array.isArray(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;
}
let tableRows = [];
let totalHours = 0;
let totalManDays = 0;
let totalLaborCost = 0;
const manDayRate = 350000; // 공수당 인건비
// 작업자별로 행 생성 (임시 데이터)
workerData.forEach(worker => {
const hours = worker.totalHours || 0;
const manDays = Math.round((hours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
const loadRate = 0; // 나중에 계산
totalHours += hours;
totalManDays += manDays;
totalLaborCost += laborCost;
tableRows.push({
projectName: `작업자 ${worker.worker_name}`,
workContent: '전체 작업',
manDays: manDays,
hours: hours,
laborCost: laborCost
});
});
// 부하율 계산 및 HTML 생성
const htmlRows = tableRows.map(row => {
const loadRate = totalHours > 0 ? ((row.hours / totalHours) * 100).toFixed(2) : '0.00';
return `
<tr class="project-group">
<td class="project-name">${row.projectName}</td>
<td class="work-content">${row.workContent}</td>
<td class="man-days">${row.manDays}</td>
<td class="load-rate">${loadRate}%</td>
<td class="labor-cost">₩${row.laborCost.toLocaleString()}</td>
</tr>
`;
});
tbody.innerHTML = htmlRows.join('');
// 합계 행 업데이트
document.getElementById('totalManDays').textContent = `${Math.round(totalManDays * 100) / 100} 공수`;
document.getElementById('totalLaborCost').textContent = `${totalLaborCost.toLocaleString()}`;
if (tfoot) {
tfoot.style.display = 'table-footer-group';
}
}
// 주말 체크 함수 및 연차/휴무 프로젝트 체크 함수는 상단에 통합 정의됨
// recent-work 데이터로 프로젝트별 취합
function aggregateProjectData(recentWorkData) {
const projectMap = new Map();
let vacationData = {
total_hours: 0,
regular_hours: 0,
error_hours: 0,
total_reports: 0
};
recentWorkData.forEach(work => {
// 연차/휴무 프로젝트인 경우
if (isVacationProject(work.project_name)) {
// 주말 연차는 제외
if (!isWeekend(work.report_date)) {
const hours = parseFloat(work.work_hours) || 0;
vacationData.total_hours += hours;
vacationData.total_reports += 1;
if (work.work_status_name === '정규') {
vacationData.regular_hours += hours;
} else if (work.work_status_name === '에러') {
vacationData.error_hours += hours;
}
}
return; // 연차/휴무는 별도 처리하므로 여기서 종료
}
const projectKey = `${work.project_id}_${work.project_name}`;
if (!projectMap.has(projectKey)) {
projectMap.set(projectKey, {
project_id: work.project_id,
project_name: work.project_name,
job_no: work.job_no || work.project_name || 'N/A',
work_types: new Map()
});
}
const project = projectMap.get(projectKey);
const workTypeKey = `${work.work_type_id}_${work.work_type_name}`;
if (!project.work_types.has(workTypeKey)) {
project.work_types.set(workTypeKey, {
work_type_id: work.work_type_id,
work_type_name: work.work_type_name,
total_hours: 0,
regular_hours: 0,
error_hours: 0,
total_reports: 0
});
}
const workType = project.work_types.get(workTypeKey);
const hours = parseFloat(work.work_hours) || 0;
workType.total_hours += hours;
workType.total_reports += 1;
if (work.work_status_name === '정규') {
workType.regular_hours += hours;
} else if (work.work_status_name === '에러') {
workType.error_hours += hours;
}
});
// Map을 배열로 변환
const projects = Array.from(projectMap.values()).map(project => ({
...project,
work_types: Array.from(project.work_types.values())
}));
// 연차/휴무 데이터가 있으면 맨 마지막에 추가
if (vacationData.total_hours > 0) {
projects.push({
project_id: 'vacation',
project_name: '연차/휴무',
job_no: '연차/휴무',
work_types: [{
work_type_id: 'vacation',
work_type_name: '-',
total_hours: vacationData.total_hours,
regular_hours: vacationData.regular_hours,
error_hours: vacationData.error_hours,
total_reports: vacationData.total_reports
}]
});
}
return { projects };
}
// recent-work 데이터로 프로젝트별 분포 테이블 렌더링
function renderProjectDistributionTableFromRecentWork(projectData, workerData) {
const tbody = document.getElementById('projectDistributionTableBody');
const tfoot = document.getElementById('projectDistributionTableFooter');
if (!tbody) return;
console.log('📋 프로젝트별 분포 테이블 렌더링 (recent-work 기반):', projectData);
if (!projectData || !projectData.projects || projectData.projects.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;
}
let tableRows = [];
let grandTotalHours = 0;
let grandTotalManDays = 0;
let grandTotalLaborCost = 0;
const manDayRate = 350000; // 공수당 인건비
// 먼저 전체 시간 계산 (부하율 계산용)
projectData.projects.forEach(project => {
project.work_types.forEach(workType => {
grandTotalHours += workType.total_hours;
});
});
// 프로젝트별로 테이블 행 생성
projectData.projects.forEach(project => {
const projectName = project.project_name || '알 수 없는 프로젝트';
const jobNo = project.job_no || project.project_name || 'N/A';
const workTypes = project.work_types || [];
let projectTotalHours = 0;
let projectTotalManDays = 0;
let projectTotalLaborCost = 0;
if (workTypes.length === 0) {
// 작업유형이 없는 경우 (빈 프로젝트)
tableRows.push(`
<tr class="project-group">
<td class="project-name">${jobNo}</td>
<td class="work-content">데이터 없음</td>
<td class="man-days">0</td>
<td class="load-rate">0.00%</td>
<td class="labor-cost">₩0</td>
</tr>
`);
} else {
// 작업유형별로 행 생성
workTypes.forEach((workType, index) => {
const workTypeName = workType.work_type_name || `작업유형 ${workType.work_type_id}`;
const workTypeHours = workType.total_hours || 0;
const manDays = Math.round((workTypeHours / 8) * 100) / 100;
const laborCost = manDays * manDayRate;
const loadRate = grandTotalHours > 0 ? ((workTypeHours / grandTotalHours) * 100).toFixed(2) : '0.00';
projectTotalHours += workTypeHours;
projectTotalManDays += manDays;
projectTotalLaborCost += laborCost;
const isFirstWorkType = index === 0;
const rowspan = workTypes.length + 1; // +1 for subtotal row
const isVacation = project.project_id === 'vacation';
const rowClass = isVacation ? 'project-group vacation-project' : 'project-group';
// 연차/휴무는 프로젝트명만, 일반 프로젝트는 Job No.만 표시
const displayText = isVacation ? projectName : jobNo;
tableRows.push(`
<tr class="${rowClass}">
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${displayText}</td>` : ''}
<td class="work-content">${workTypeName}</td>
<td class="man-days">${manDays}</td>
<td class="load-rate">${loadRate}%</td>
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
</tr>
`);
});
// 프로젝트별 합계 행 추가 (연차/휴무는 합계 행 제외)
if (project.project_id !== 'vacation') {
const projectLoadRate = grandTotalHours > 0 ? ((projectTotalHours / grandTotalHours) * 100).toFixed(2) : '0.00';
tableRows.push(`
<tr class="project-subtotal">
<td class="work-content"><strong>합계</strong></td>
<td class="man-days"><strong>${Math.round(projectTotalManDays * 100) / 100}</strong></td>
<td class="load-rate"><strong>${projectLoadRate}%</strong></td>
<td class="labor-cost"><strong>₩${projectTotalLaborCost.toLocaleString()}</strong></td>
</tr>
`);
}
}
grandTotalManDays += projectTotalManDays;
grandTotalLaborCost += projectTotalLaborCost;
});
tbody.innerHTML = tableRows.join('');
// 전체 합계 행 업데이트
document.getElementById('totalManDays').textContent = `${Math.round(grandTotalManDays * 100) / 100} 공수`;
document.getElementById('totalLaborCost').textContent = `${grandTotalLaborCost.toLocaleString()}`;
if (tfoot) {
tfoot.style.display = 'table-footer-group';
}
}
// 분석 모드 전환
function switchAnalysisMode(mode) {
// 탭 활성화 상태 변경
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-mode="${mode}"]`).classList.add('active');
// 프로젝트 선택 그룹은 제거되었으므로 이 로직은 불필요
// const projectGroup = document.getElementById('projectSelectGroup');
// if (mode === 'period') {
// projectGroup.style.display = 'block';
// } else {
// projectGroup.style.display = 'none';
// }
// 현재 모드 저장
window.currentAnalysisMode = mode;
}
// 프로젝트 목록 로드
async function loadProjects() {
try {
const response = await window.apiCall('/projects/active/list', 'GET');
const projects = response.data || response;
// 프로젝트 선택 드롭다운이 제거되었으므로 이 로직은 불필요
// const select = document.getElementById('projectSelect');
// select.innerHTML = '<option value="">전체 프로젝트</option>';
//
// projects.forEach(project => {
// const option = document.createElement('option');
// option.value = project.project_id;
// option.textContent = project.project_name;
// select.appendChild(option);
// });
} catch (error) {
console.error('프로젝트 로드 실패:', error);
}
}
// 분석 실행
async function performAnalysis() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const projectId = ''; // 프로젝트 선택이 제거되었으므로 빈 값으로 설정
console.log('🔍 분석 실행 요청:', { startDate, endDate, projectId });
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
// 날짜 유효성 검사
const start = new Date(startDate);
const end = new Date(endDate);
if (start > end) {
alert('시작일이 종료일보다 늦을 수 없습니다.');
return;
}
// 서울 표준시 기준으로 날짜 범위 확인
console.log('📅 분석 기간:', {
start: formatDateToString(start),
end: formatDateToString(end),
days: Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
});
// 로딩 상태 표시
showLoading();
try {
const mode = window.currentAnalysisMode || 'period';
if (mode === 'period') {
await performPeriodAnalysis(startDate, endDate, projectId);
} else {
await performProjectAnalysis(startDate, endDate);
}
showResults();
} catch (error) {
console.error('분석 실행 실패:', error);
alert('분석 실행 중 오류가 발생했습니다.');
showEmpty();
}
}
// 기간별 분석
async function performPeriodAnalysis(startDate, endDate, projectId) {
console.log('📊 기간별 분석 실행:', { startDate, endDate, projectId });
try {
// 실제 API 호출
const params = new URLSearchParams({
start: startDate,
end: endDate
});
if (projectId) {
params.append('project_id', projectId);
}
console.log('📡 API 호출 파라미터:', params.toString());
// 배치 API 호출로 최적화
const batchRequests = [
{
name: 'stats',
endpoint: `/work-analysis/stats?${params}`,
method: 'GET',
useCache: true
}
];
const results = await WorkAnalysisAPI.batchCall(batchRequests);
const statsResult = results.find(r => r.name === 'stats');
console.log('📊 통계 API 응답 (배치):', statsResult);
if (statsResult && statsResult.success && statsResult.data.success) {
const stats = statsResult.data.data;
// 정상/오류 시간 계산
const totalHours = stats.totalHours || 0;
const errorReports = stats.errorRate || 0;
const errorHours = Math.round(totalHours * (errorReports / 100));
const normalHours = totalHours - errorHours;
updateResultCards({
totalHours: totalHours,
normalHours: normalHours,
errorHours: errorHours,
workerCount: stats.activeworkers || stats.activeWorkers || 0,
errorRate: errorReports
});
// 추가 데이터 로드 및 차트 렌더링
await loadChartsData(startDate, endDate, projectId);
} else {
console.warn('⚠️ API 응답에 데이터가 없음:', statsResponse);
// 데이터가 없어도 차트는 빈 상태로 표시
updateResultCards({
totalHours: 0,
normalHours: 0,
errorHours: 0,
workerCount: 0,
errorRate: 0
});
// 빈 차트 표시
await loadChartsData(startDate, endDate, projectId);
}
} catch (error) {
console.error('❌ 기간별 분석 API 오류:', error);
// 오류 시 기본값 표시
updateResultCards({
totalHours: 0,
normalHours: 0,
errorHours: 0,
workerCount: 0,
errorRate: 0
});
}
}
// 프로젝트별 분석
async function performProjectAnalysis(startDate, endDate) {
console.log('🏗️ 프로젝트별 분석 실행:', { startDate, endDate });
try {
// 실제 API 호출
const params = new URLSearchParams({
start: startDate,
end: endDate
});
console.log('📡 프로젝트별 API 호출 파라미터:', params.toString());
// 프로젝트별 통계 조회
const projectStatsResponse = await window.apiCall(`/work-analysis/project-stats?${params}`, 'GET');
console.log('🏗️ 프로젝트 통계 API 응답:', projectStatsResponse);
// 기본 통계도 함께 조회
const statsResponse = await window.apiCall(`/work-analysis/stats?${params}`, 'GET');
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;
updateResultCards({
totalHours: totalHours,
normalHours: normalHours,
errorHours: errorHours,
workerCount: stats.activeworkers || stats.activeWorkers || 0,
errorRate: errorReports
});
} else {
console.warn('⚠️ 프로젝트별 분석 API 응답에 데이터가 없음');
updateResultCards({
totalHours: 0,
normalHours: 0,
errorHours: 0,
workerCount: 0,
errorRate: 0
});
}
} catch (error) {
console.error('❌ 프로젝트별 분석 API 오류:', error);
updateResultCards({
totalHours: 0,
normalHours: 0,
errorHours: 0,
workerCount: 0,
errorRate: 0
});
}
}
// 결과 카드 업데이트
function updateResultCards(data) {
document.getElementById('totalHours').textContent = data.totalHours;
document.getElementById('normalHours').textContent = data.normalHours;
document.getElementById('errorHours').textContent = data.errorHours;
document.getElementById('workerCount').textContent = data.workerCount;
// 퍼센트 계산 및 업데이트
const normalPercent = ((data.normalHours / data.totalHours) * 100).toFixed(1);
const errorPercent = data.errorRate.toFixed(1);
const avgHours = (data.totalHours / data.workerCount).toFixed(1);
document.getElementById('normalHoursPercent').textContent = normalPercent + '%';
document.getElementById('errorRate').textContent = errorPercent + '%';
document.getElementById('avgHoursPerWorker').textContent = avgHours + 'h';
// 프로그레스 바 업데이트
document.getElementById('normalHoursProgress').style.width = normalPercent + '%';
document.getElementById('errorHoursProgress').style.width = errorPercent + '%';
document.getElementById('totalHoursProgress').style.width = '85%'; // 목표 대비
document.getElementById('workerProgress').style.width = '75%'; // 전체 작업자 대비
}
// UI 상태 관리 함수들은 WorkAnalysis.ui 네임스페이스로 이동됨
// 차트 데이터 로드 및 렌더링
async function loadChartsData(startDate, endDate, projectId) {
console.log('📈 차트 데이터 로딩 시작...');
try {
const params = new URLSearchParams({
start: startDate,
end: endDate
});
if (projectId) {
params.append('project_id', projectId);
}
// 여러 API를 병렬로 호출
const [dailyTrendRes, workerStatsRes, projectStatsRes, errorAnalysisRes] = await Promise.all([
window.apiCall(`/work-analysis/daily-trend?${params}`, 'GET').catch(err => {
console.warn('일별 추이 API 오류:', err);
return { success: false, data: [] };
}),
window.apiCall(`/work-analysis/worker-stats?${params}`, 'GET').catch(err => {
console.warn('작업자 통계 API 오류:', err);
return { success: false, data: [] };
}),
window.apiCall(`/work-analysis/project-stats?${params}`, 'GET').catch(err => {
console.warn('프로젝트 통계 API 오류:', err);
return { success: false, data: [] };
}),
window.apiCall(`/work-analysis/error-analysis?${params}`, 'GET').catch(err => {
console.warn('오류 분석 API 오류:', err);
return { success: false, data: [] };
})
]);
console.log('📊 차트 API 응답들:', {
dailyTrend: dailyTrendRes,
workerStats: workerStatsRes,
projectStats: projectStatsRes,
errorAnalysis: errorAnalysisRes
});
// 차트 렌더링 (기간별 작업 현황은 개별 버튼으로 실행)
// renderTimeSeriesChart(startDate, endDate, projectId);
renderProjectDistributionChart(projectStatsRes.data || []);
renderWorkerPerformanceChart(workerStatsRes.data || []);
renderErrorAnalysisChart(errorAnalysisRes.data || []);
// 상세 데이터 테이블 렌더링
renderDetailDataTable(startDate, endDate, projectId);
} catch (error) {
console.error('❌ 차트 데이터 로딩 오류:', error);
// 오류 시에도 빈 차트 표시 (기간별 작업 현황은 개별 버튼으로 실행)
// renderTimeSeriesChart('', '', '');
renderProjectDistributionChart([]);
renderWorkerPerformanceChart([]);
renderErrorAnalysisChart([]);
}
}
// 기간별 작업 현황 테이블 렌더링 (작업자별 → 프로젝트별 → 작업유형별)
async function renderTimeSeriesChart(startDate, endDate, projectId = '') {
try {
// 프로젝트별-작업유형별 상세 데이터 조회
const params = new URLSearchParams({
start: startDate || confirmedStartDate,
end: endDate || confirmedEndDate
});
// 프로젝트 ID가 제공된 경우에만 필터링 적용
if (projectId) {
params.append('project_id', projectId);
}
console.log('📊 작업 현황 테이블 데이터 로딩...');
// 프로젝트-작업유형, 작업자 데이터를 먼저 가져오기
const [detailResponse, workerResponse] = await Promise.all([
window.apiCall(`/work-analysis/project-worktype-analysis?${params}`, 'GET'),
window.apiCall(`/work-analysis/worker-stats?${params}`, 'GET')
]);
// 상세 작업 데이터를 한 번에 많이 가져오기 (서버 limit 증가로 인해 가능)
console.log('📊 상세 작업 데이터 로딩...');
let allRecentWorkData = [];
try {
// 먼저 큰 limit으로 시도 (한 달 데이터라면 보통 1000-2000개 정도)
const recentWorkResponse = await window.apiCall(`/work-analysis/recent-work?${params}&limit=2000`, 'GET');
if (recentWorkResponse.success && recentWorkResponse.data) {
allRecentWorkData = recentWorkResponse.data;
console.log(`📊 ${allRecentWorkData.length}개의 상세 작업 데이터 수집 완료`);
} else {
console.warn('📊 상세 작업 데이터가 없습니다');
}
} catch (error) {
console.error('📊 상세 작업 데이터 로딩 실패:', error);
// 실패하면 작은 limit으로 재시도
try {
const fallbackResponse = await window.apiCall(`/work-analysis/recent-work?${params}&limit=100`, 'GET');
if (fallbackResponse.success && fallbackResponse.data) {
allRecentWorkData = fallbackResponse.data;
console.log(`📊 폴백으로 ${allRecentWorkData.length}개의 상세 작업 데이터 수집 완료`);
}
} catch (fallbackError) {
console.error('📊 폴백 데이터 로딩도 실패:', fallbackError);
allRecentWorkData = [];
}
}
console.log('📊 프로젝트-작업유형 API 응답:', detailResponse);
console.log('📊 작업자 통계 API 응답:', workerResponse);
if (detailResponse.success && detailResponse.data) {
// 모든 데이터를 함께 전달
const workerData = workerResponse.success ? workerResponse.data : [];
renderWorkReportTable(detailResponse.data, workerData, allRecentWorkData);
} else {
// 데이터가 없으면 빈 테이블 표시
renderEmptyWorkReportTable();
}
} catch (error) {
console.error('❌ 작업 현황 테이블 렌더링 오류:', error);
renderEmptyWorkReportTable(`데이터 로딩 실패: ${error.message}`);
}
}
// 작업 현황 테이블 렌더링
function renderWorkReportTable(apiResponse, workerData = [], recentWorkData = []) {
const tbody = document.getElementById('workReportTableBody');
const tfoot = document.getElementById('workReportTableFooter');
if (!tbody) return;
console.log('🔄 작업 현황 테이블 데이터 처리 중:', apiResponse);
let tableRows = [];
let totalHours = 0;
let totalManDays = 0;
// 주말 체크 함수
// 주말 체크 함수 및 연차/휴무 프로젝트 체크 함수는 상단에 통합 정의됨
// 상세 데이터에서 작업자별 실제 작업일수 계산 (주말 연차/휴무 제외)
const workerActualWorkDays = {};
if (recentWorkData && Array.isArray(recentWorkData)) {
recentWorkData.forEach(work => {
const workerName = work.worker_name || '알 수 없음';
const projectName = work.project_name || '알 수 없는 프로젝트';
const workDate = work.report_date;
// 주말 연차/휴무는 작업일수 계산에서 제외
if (isVacationProject(projectName) && isWeekend(workDate)) {
return; // 작업일수에 포함하지 않음
}
if (!workerActualWorkDays[workerName]) {
workerActualWorkDays[workerName] = new Set();
}
// 같은 날짜에 여러 작업이 있어도 작업일은 1일로 계산
if (workDate) {
workerActualWorkDays[workerName].add(formatSimpleDate(workDate));
}
});
}
// 작업자별 전체 통계 계산 (작업공수, 작업일 계산용) - 주말 연차/휴무 제외된 시간 계산
const workerTotalStats = {};
// 작업자별 실제 총 시간 계산 (주말 연차/휴무 제외)
const workerActualTotalHours = {};
if (recentWorkData && Array.isArray(recentWorkData)) {
recentWorkData.forEach(work => {
const workerName = work.worker_name || '알 수 없음';
const projectName = work.project_name || '알 수 없는 프로젝트';
const workHours = parseFloat(work.work_hours) || 0;
const workDate = work.report_date;
// 주말 연차/휴무는 총 시간에서 제외
if (isVacationProject(projectName) && isWeekend(workDate)) {
return; // 총 시간에 포함하지 않음
}
if (!workerActualTotalHours[workerName]) {
workerActualTotalHours[workerName] = 0;
}
workerActualTotalHours[workerName] += workHours;
});
}
if (workerData && Array.isArray(workerData)) {
workerData.forEach(worker => {
const workerName = worker.worker_name || worker.name || '알 수 없음';
// 실제 계산된 총 시간 사용 (주말 연차/휴무 제외)
const workerTotalHours = workerActualTotalHours[workerName] || 0;
// 실제 작업일수 사용 (상세 데이터에서 계산된 값)
const actualWorkDays = workerActualWorkDays[workerName] ? workerActualWorkDays[workerName].size : Math.ceil(workerTotalHours / 8);
workerTotalStats[workerName] = {
totalHours: workerTotalHours,
workDays: actualWorkDays,
manDays: Math.round((workerTotalHours / 8) * 100) / 100,
avgHours: actualWorkDays > 0 ? Math.round((workerTotalHours / actualWorkDays) * 100) / 100 : 0
};
});
}
console.log('👥 작업자별 전체 통계:', workerTotalStats);
// 작업자별로 데이터 그룹화 (프로젝트별로 먼저 그룹화)
const workerGroupedData = {};
// 상세 데이터에서 작업자별 프로젝트-작업유형 데이터 수집
if (recentWorkData && Array.isArray(recentWorkData)) {
recentWorkData.forEach(work => {
const workerName = work.worker_name || '알 수 없음';
const projectName = work.project_name || '알 수 없는 프로젝트';
const workTypeName = work.work_type_name || `작업유형 ${work.work_type_id}`;
const workHours = parseFloat(work.work_hours) || 0;
const workDate = work.report_date;
// 주말 연차/휴무는 집계에서 제외
if (isVacationProject(projectName) && isWeekend(workDate)) {
console.log(`🚫 주말 연차/휴무 제외: ${workerName} - ${projectName} (${formatSimpleDate(workDate)})`);
return; // 집계하지 않음
}
if (!workerGroupedData[workerName]) {
workerGroupedData[workerName] = {};
}
if (!workerGroupedData[workerName][projectName]) {
workerGroupedData[workerName][projectName] = {};
}
// 연차/휴무 프로젝트는 작업내용을 통합
let finalWorkTypeName = workTypeName;
if (isVacationProject(projectName)) {
finalWorkTypeName = '-'; // 연차/휴무는 작업내용을 '-'로 통합
console.log(`🏖️ 연차/휴무 통합: ${workerName} - ${projectName} (${workTypeName} → -)`);
}
if (!workerGroupedData[workerName][projectName][finalWorkTypeName]) {
workerGroupedData[workerName][projectName][finalWorkTypeName] = 0;
}
workerGroupedData[workerName][projectName][finalWorkTypeName] += workHours;
totalHours += workHours;
});
}
console.log('👥 작업자별 그룹화된 데이터:', workerGroupedData);
// 작업자별로 테이블 행 생성 (프로젝트별 그룹화 + rowspan 사용)
let workerIndex = 0;
const workerNames = Object.keys(workerGroupedData);
Object.entries(workerGroupedData).forEach(([workerName, workerProjects]) => {
const workerStats = workerTotalStats[workerName];
if (workerStats && !workerStats.counted) {
totalManDays += workerStats.manDays;
workerStats.counted = true;
}
// 작업자의 총 행 수 계산 (모든 프로젝트의 작업유형 합계)
let totalWorkerRows = 0;
Object.values(workerProjects).forEach(workTypes => {
totalWorkerRows += Object.keys(workTypes).length;
});
let workerRowIndex = 0;
const isFirstWorker = workerIndex === 0;
const isLastWorker = workerIndex === workerNames.length - 1;
// 프로젝트 정렬: 연차/휴무를 맨 아래로
const sortedProjects = Object.entries(workerProjects).sort(([projectNameA], [projectNameB]) => {
const isVacationA = isVacationProject(projectNameA);
const isVacationB = isVacationProject(projectNameB);
// 연차/휴무가 아닌 것을 위로, 연차/휴무를 아래로
if (isVacationA && !isVacationB) return 1; // A가 연차/휴무면 아래로
if (!isVacationA && isVacationB) return -1; // B가 연차/휴무면 A를 위로
// 둘 다 연차/휴무이거나 둘 다 일반 프로젝트면 이름순 정렬
return projectNameA.localeCompare(projectNameB);
});
console.log(`👤 ${workerName} 프로젝트 정렬 결과:`, sortedProjects.map(([name]) => name));
// 정렬된 프로젝트별로 처리
sortedProjects.forEach(([projectName, workTypes]) => {
const workTypeEntries = Object.entries(workTypes);
const projectRowCount = workTypeEntries.length;
// 작업유형별로 행 생성
workTypeEntries.forEach(([workTypeName, hours], workTypeIndex) => {
const isFirstWorkerRow = workerRowIndex === 0;
const isFirstProjectRow = workTypeIndex === 0;
const isLastProjectRow = workTypeIndex === projectRowCount - 1;
const isLastWorkerRow = workerRowIndex === totalWorkerRows - 1;
let rowClass = 'worker-group';
if (isFirstWorkerRow) rowClass += ' worker-group-first';
if (isLastWorkerRow) rowClass += ' worker-group-last';
if (isFirstProjectRow) rowClass += ' project-group-first';
if (isLastProjectRow) rowClass += ' project-group-last';
// 연차/휴무 프로젝트 구분 클래스 추가
if (isVacationProject(projectName)) {
rowClass += ' vacation-project';
}
if (workerStats) {
tableRows.push(`
<tr class="${rowClass}">
${isFirstWorkerRow ? `<td class="worker-name" rowspan="${totalWorkerRows}">${workerName}</td>` : ''}
${isFirstProjectRow ? `<td class="project-name" rowspan="${projectRowCount}">${projectName}</td>` : ''}
<td class="work-content">${workTypeName}</td>
<td class="input-hours">${Math.round(hours * 100) / 100}h</td>
${isFirstWorkerRow ? `<td class="man-days" rowspan="${totalWorkerRows}">${workerStats.manDays} 공수</td>` : ''}
${isFirstWorkerRow ? `<td class="work-days" rowspan="${totalWorkerRows}">${workerStats.workDays}일 / ${workerStats.avgHours}시간</td>` : ''}
${isFirstWorkerRow ? `<td rowspan="${totalWorkerRows}"></td>` : ''}
</tr>
`);
} else {
tableRows.push(`
<tr class="${rowClass}">
${isFirstWorkerRow ? `<td class="worker-name" rowspan="${totalWorkerRows}">${workerName}</td>` : ''}
${isFirstProjectRow ? `<td class="project-name" rowspan="${projectRowCount}">${projectName}</td>` : ''}
<td class="work-content">${workTypeName}</td>
<td class="input-hours">${Math.round(hours * 100) / 100}h</td>
${isFirstWorkerRow ? `<td class="man-days" rowspan="${totalWorkerRows}">-</td>` : ''}
${isFirstWorkerRow ? `<td class="work-days" rowspan="${totalWorkerRows}">-</td>` : ''}
${isFirstWorkerRow ? `<td rowspan="${totalWorkerRows}"></td>` : ''}
</tr>
`);
}
workerRowIndex++;
});
});
workerIndex++;
});
// 데이터가 없는 경우
if (tableRows.length === 0) {
renderEmptyWorkReportTable('해당 기간에 작업 데이터가 없습니다');
return;
}
// 테이블 업데이트
tbody.innerHTML = tableRows.join('');
// 총계 업데이트
document.getElementById('totalHoursCell').textContent = `${Math.round(totalHours * 100) / 100}시간`;
document.getElementById('totalManDaysCell').textContent = `${Math.round(totalManDays * 100) / 100} 공수`;
// 푸터 표시
if (tfoot) {
tfoot.style.display = 'table-footer-group';
}
console.log('✅ 작업 현황 테이블 렌더링 완료:', { totalHours, totalManDays, rows: tableRows.length });
}
// 빈 테이블 표시
function renderEmptyWorkReportTable(message = '분석을 실행하여 데이터를 확인하세요') {
const tbody = document.getElementById('workReportTableBody');
const tfoot = document.getElementById('workReportTableFooter');
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: #666;">
${message}
</td>
</tr>
`;
}
if (tfoot) {
tfoot.style.display = 'none';
}
}
// 프로젝트 분포 차트 렌더링
function renderProjectDistributionChart(data) {
const ctx = document.getElementById('projectDistributionChart');
if (!ctx) return;
if (window.projectDistributionChart && typeof window.projectDistributionChart.destroy === 'function') {
window.projectDistributionChart.destroy();
}
// 데이터가 없으면 기본 메시지 표시
let labels, values;
if (!data || data.length === 0) {
labels = ['데이터 없음'];
values = [0];
} else {
labels = data.map(item => item.project_name || item.name || '프로젝트 없음');
values = data.map(item => item.totalHours || item.total_hours || 0);
}
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
window.projectDistributionChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: colors.slice(0, labels.length),
borderWidth: 2,
borderColor: '#ffffff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right'
}
}
}
});
}
// 작업자 성과 차트 렌더링
function renderWorkerPerformanceChart(data) {
const ctx = document.getElementById('workerPerformanceChart');
if (!ctx) return;
if (window.workerPerformanceChart && typeof window.workerPerformanceChart.destroy === 'function') {
window.workerPerformanceChart.destroy();
}
// 데이터가 없으면 기본 메시지 표시
let labels, values;
if (!data || data.length === 0) {
labels = ['데이터 없음'];
values = [0];
} else {
labels = data.map(item => item.worker_name || item.name || '작업자 없음');
values = data.map(item => item.totalHours || item.total_hours || 0);
}
window.workerPerformanceChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '작업 시간',
data: values,
backgroundColor: '#10b981',
borderColor: '#059669',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '작업 시간 (시간)'
}
}
}
}
});
}
// 오류 분석 차트 렌더링
function renderErrorAnalysisChart(data) {
const ctx = document.getElementById('errorAnalysisChart');
if (!ctx) return;
if (window.errorAnalysisChart && typeof window.errorAnalysisChart.destroy === 'function') {
window.errorAnalysisChart.destroy();
}
// 데이터가 없으면 기본 메시지 표시
let labels, values;
if (!data || data.length === 0) {
labels = ['오류 없음'];
values = [0];
} else {
labels = data.map(item => item.error_type_name || item.name || '오류 유형 없음');
values = data.map(item => item.error_count || item.count || 0);
}
window.errorAnalysisChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '오류 발생 횟수',
data: values,
backgroundColor: '#ef4444',
borderColor: '#dc2626',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '오류 발생 횟수'
}
}
}
}
});
}
// 상세 데이터 테이블 렌더링
async function renderDetailDataTable(startDate, endDate, projectId) {
try {
const params = new URLSearchParams({
start: startDate,
end: endDate,
limit: 100
});
if (projectId) {
params.append('project_id', projectId);
}
const recentWorkRes = await window.apiCall(`/work-analysis/recent-work?${params}`, 'GET');
console.log('📋 상세 데이터 API 응답:', recentWorkRes);
const tbody = document.getElementById('detailDataBody');
if (!tbody) return;
if (recentWorkRes.success && recentWorkRes.data && recentWorkRes.data.length > 0) {
tbody.innerHTML = recentWorkRes.data.map(item => `
<tr>
<td>${formatSimpleDate(item.report_date)}</td>
<td>${item.worker_name || '작업자 없음'}</td>
<td>${item.project_name || '프로젝트 없음'}</td>
<td>${item.work_type_name || '작업 유형 없음'}</td>
<td>${item.work_hours || 0}시간</td>
<td>
<span class="status-badge ${item.work_status_id === 2 ? 'error' : 'success'}">
${item.work_status_id === 2 ? '오류' : '정상'}
</span>
</td>
<td>${item.error_type_name || '-'}</td>
</tr>
`).join('');
} else {
tbody.innerHTML = `
<tr>
<td colspan="7" style="text-align: center; padding: 40px; color: #6b7280;">
선택한 기간에 작업 데이터가 없습니다.
</td>
</tr>
`;
}
} catch (error) {
console.error('❌ 상세 데이터 테이블 렌더링 오류:', error);
const tbody = document.getElementById('detailDataBody');
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="7" style="text-align: center; padding: 40px; color: #ef4444;">
데이터 로딩 중 오류가 발생했습니다.
</td>
</tr>
`;
}
}
}
// 초기 모드 설정
window.currentAnalysisMode = 'period';
</script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
</body>
</html>