카카오톡 인앱 WebView는 서브도메인 간 쿠키를 공유하지 않아 tkds에서 로그인 후 tkfb로 리다이렉트 시 인증이 풀리는 문제. - sso-relay.js: URL hash의 _sso= 토큰을 로컬 쿠키+localStorage로 설정 - gateway dashboard: 로그인 후 redirect URL에 #_sso=<token> 추가 - 전 서비스 HTML: core JS 직전에 sso-relay.js 로드 (81개 파일) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2876 lines
134 KiB
HTML
2876 lines
134 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>작업 분석 - TK 공장관리</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||
<link rel="stylesheet" href="/css/work-analysis.css?v=2026031401">
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||
</head>
|
||
<body class="bg-gray-50">
|
||
<header class="bg-orange-700 text-white sticky top-0 z-50">
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div class="flex justify-between items-center h-14">
|
||
<div class="flex items-center gap-3">
|
||
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
|
||
<i class="fas fa-industry text-xl text-orange-200"></i>
|
||
<h1 class="text-lg font-semibold">TK 공장관리</h1>
|
||
</div>
|
||
<div class="flex items-center gap-4">
|
||
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
|
||
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
|
||
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
||
<div class="flex gap-6">
|
||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
|
||
<div class="flex-1 min-w-0">
|
||
<!-- 뒤로가기 버튼 -->
|
||
<a href="javascript:history.back()" class="back-button" style="margin: 1rem 0;">
|
||
← 뒤로가기
|
||
</a>
|
||
|
||
<!-- 페이지 헤더 -->
|
||
<header class="page-header">
|
||
<h1 class="page-title">작업 분석</h1>
|
||
<p class="page-subtitle">기간별/프로젝트별 작업 현황을 분석하고 통계를 확인합니다</p>
|
||
</header>
|
||
|
||
<!-- 분석 모드 탭 -->
|
||
<nav class="analysis-tabs">
|
||
<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">
|
||
<div class="controls-grid">
|
||
<!-- 기간 설정 -->
|
||
<div class="form-group">
|
||
<label class="form-label" for="startDate">시작일</label>
|
||
<input type="date" id="startDate" class="form-input" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="endDate">종료일</label>
|
||
<input type="date" id="endDate" class="form-input" required>
|
||
</div>
|
||
|
||
<!-- 기간 확정 버튼 -->
|
||
<div class="form-group">
|
||
<button class="confirm-period-button" onclick="confirmPeriod()">기간 확정</button>
|
||
</div>
|
||
|
||
<!-- 기간 상태 표시 -->
|
||
<div class="form-group" id="periodStatusGroup" style="display: none;">
|
||
<div class="period-status">
|
||
<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">
|
||
<!-- 로딩 상태 -->
|
||
<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">
|
||
기간별 작업 현황
|
||
</button>
|
||
<button class="tab-button" data-tab="project-distribution">
|
||
프로젝트별 분포
|
||
</button>
|
||
<button class="tab-button" data-tab="worker-performance">
|
||
작업자별 성과
|
||
</button>
|
||
<button class="tab-button" data-tab="error-analysis">
|
||
오류 분석
|
||
</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">기간별 작업 현황</h3>
|
||
<button class="chart-analyze-btn" onclick="analyzeWorkStatus()" disabled>분석 실행</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">프로젝트별 작업 분포</h3>
|
||
<button class="chart-analyze-btn" onclick="analyzeProjectDistribution()" disabled>분석 실행</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">작업자별 성과</h3>
|
||
<button class="chart-analyze-btn" onclick="analyzeWorkerPerformance()" disabled>분석 실행</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">오류 분석</h3>
|
||
<button class="chart-analyze-btn" onclick="analyzeErrorAnalysis()" disabled>분석 실행</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">상세 작업 데이터</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>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/sso-relay.js?v=20260401"></script>
|
||
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
|
||
<script src="/js/api-base.js?v=2026031401"></script>
|
||
<script type="module" src="/js/work-analysis.js?v=2026031401"></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('[Cache] 캐시에서 데이터 반환:', 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('[Error] API 호출 실패:', endpoint, error);
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
// 배치 API 호출 (병렬 처리)
|
||
async batchCall(requests) {
|
||
console.log('[Batch] 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(`[Warning] ${req.name} API 실패:`, error);
|
||
return { name: req.name, success: false, error: error.message, data: null };
|
||
}
|
||
});
|
||
|
||
const results = await Promise.all(promises);
|
||
console.log('[Complete] 배치 API 호출 완료');
|
||
return results;
|
||
},
|
||
|
||
// 캐시 초기화
|
||
clearCache() {
|
||
this.cache.clear();
|
||
console.log('[Clear] 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' ? '[v]' : '[i]'}</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(`[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 hours = String(now.getHours()).padStart(2, '0');
|
||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||
const timeString = `${hours}시 ${minutes}분 ${seconds}초`;
|
||
|
||
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;
|
||
// work_status_id: 1=정상, 2=오류
|
||
if (work.work_status_id === 2 || work.error_type_id) {
|
||
vacationData.errorHours += hours;
|
||
const errorTypeName = work.error_type_name || work.error_category_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 {
|
||
// 일반 프로젝트 처리 - API에서 반환된 공정(대분류) 사용
|
||
const projectName = work.project_name || `프로젝트 ${work.project_id}`;
|
||
const workTypeName = work.work_type_name || '기타';
|
||
|
||
// 공정(대분류) 기준으로 집계 (프로젝트별로 구분)
|
||
const combinedKey = `${work.project_id || 'unknown'}_${work.work_type_id || 0}`;
|
||
|
||
if (!workTypeMap.has(combinedKey)) {
|
||
workTypeMap.set(combinedKey, {
|
||
project_id: work.project_id,
|
||
project_name: projectName,
|
||
job_no: work.job_no,
|
||
work_type_id: work.work_type_id,
|
||
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;
|
||
|
||
// work_status_id: 1=정상, 2=오류
|
||
if (work.work_status_id === 2 || work.error_type_id) {
|
||
workTypeData.errorHours += hours;
|
||
|
||
// 오류 유형별 세분화
|
||
const errorTypeName = work.error_type_name || work.error_category_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);
|
||
|
||
// API에서 반환된 공정(대분류) 사용
|
||
const workTypeName = work.work_type_name || '기타';
|
||
const workTypeKey = `${work.work_type_id || 0}_${workTypeName}`;
|
||
|
||
if (!project.work_types.has(workTypeKey)) {
|
||
project.work_types.set(workTypeKey, {
|
||
work_type_id: work.work_type_id,
|
||
work_type_name: workTypeName,
|
||
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 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] = {};
|
||
}
|
||
|
||
// 작업내용 - API에서 공정(대분류) 이름을 직접 반환
|
||
let finalWorkTypeName;
|
||
if (isVacationProject(projectName)) {
|
||
finalWorkTypeName = '-'; // 연차/휴무는 작업내용을 '-'로 통합
|
||
} else {
|
||
// API에서 반환된 work_type_name 사용 (공정/대분류)
|
||
finalWorkTypeName = work.work_type_name || '기타';
|
||
}
|
||
|
||
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>initAuth();</script>
|
||
</body>
|
||
</html>
|