Files
TK-FB-Project/web-ui/pages.backup.20260202/.archived-analysis-legacy.html
Hyungi Ahn 74d3a78aa3 feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현
- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성
  - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동
  - common/ → attendance/: 근태/휴가 관련 페이지 이동
  - admin/ 정리: safety-* 파일들을 safety/로 이동

- 사이드바 네비게이션 메뉴 구현
  - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리
  - 접기/펼치기 기능 및 상태 저장
  - 관리자 전용 메뉴 자동 표시/숨김

- 날씨 API 연동 (기상청 단기예보)
  - TBM 및 navbar에 현재 날씨 표시
  - weatherService.js 추가

- 안전 체크리스트 확장
  - 기본/날씨별/작업별 체크 유형 추가
  - checklist-manage.html 페이지 추가

- 이슈 신고 시스템 구현
  - workIssueController, workIssueModel, workIssueRoutes 추가

- DB 마이그레이션 파일 추가 (실행 대기)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:27:22 +09:00

2233 lines
105 KiB
HTML

<!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=1" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
</head>
<body>
<div class="analysis-container">
<!-- 페이지 헤더 -->
<header class="page-header fade-in">
<h1 class="page-title">
<span class="icon">📊</span>
작업 분석
</h1>
<p class="page-subtitle">기간별/프로젝트별 작업 현황을 분석하고 통계를 확인합니다</p>
</header>
<!-- 분석 모드 탭 -->
<nav class="analysis-tabs fade-in">
<button class="tab-button active" data-mode="period" 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 class="result-card">
<div class="card-header">
<h3 class="card-title">
<span class="icon"></span>
총 작업 시간
</h3>
</div>
<div class="card-value" id="totalHours">0</div>
<p class="card-subtitle">시간</p>
<div class="card-progress">
<div class="progress-bar" id="totalHoursProgress" style="width: 0%"></div>
</div>
<div class="card-stats">
<span>목표 대비</span>
<span id="totalHoursPercent">0%</span>
</div>
</div>
<!-- 정상 작업 시간 카드 -->
<div class="result-card success">
<div class="card-header">
<h3 class="card-title">
<span class="icon"></span>
정상 작업
</h3>
</div>
<div class="card-value" id="normalHours">0</div>
<p class="card-subtitle">시간</p>
<div class="card-progress">
<div class="progress-bar success" id="normalHoursProgress" style="width: 0%"></div>
</div>
<div class="card-stats">
<span>전체 대비</span>
<span id="normalHoursPercent">0%</span>
</div>
</div>
<!-- 오류 작업 시간 카드 -->
<div class="result-card error">
<div class="card-header">
<h3 class="card-title">
<span class="icon"></span>
오류 작업
</h3>
</div>
<div class="card-value" id="errorHours">0</div>
<p class="card-subtitle">시간</p>
<div class="card-progress">
<div class="progress-bar error" id="errorHoursProgress" style="width: 0%"></div>
</div>
<div class="card-stats">
<span>오류율</span>
<span id="errorRate">0%</span>
</div>
</div>
<!-- 참여 작업자 수 카드 -->
<div class="result-card info">
<div class="card-header">
<h3 class="card-title">
<span class="icon">👥</span>
참여 작업자
</h3>
</div>
<div class="card-value" id="workerCount">0</div>
<p class="card-subtitle"></p>
<div class="card-progress">
<div class="progress-bar" id="workerProgress" style="width: 0%"></div>
</div>
<div class="card-stats">
<span>평균 작업시간</span>
<span id="avgHoursPerWorker">0h</span>
</div>
</div>
</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>
<!-- 대시보드 이동 버튼 (고정) -->
<div style="position: fixed; top: 20px; right: 20px; z-index: 1000;">
<a href="/pages/dashboard/group-leader.html"
style="display: inline-flex; align-items: center; gap: 8px; padding: 12px 20px;
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%);
color: white; text-decoration: none; border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
font-weight: 600; transition: all 0.2s ease;">
<span>📊</span>
<span>대시보드</span>
</a>
</div>
<!-- JavaScript -->
<script src="/js/work-analysis.js?v=4"></script>
<!-- 모듈화된 JavaScript 로딩 -->
<script src="/js/work-analysis/module-loader.js?v=1" defer></script>
<script>
// 서울 표준시(KST) 기준 날짜 함수들 (하위 호환성 유지)
function getKSTDate() {
const now = new Date();
// UTC 시간에 9시간 추가 (KST = UTC+9)
const kstOffset = 9 * 60; // 9시간을 분으로 변환
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
const kst = new Date(utc + (kstOffset * 60000));
return kst;
}
function formatDateToString(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 날짜 문자열을 간단한 형식으로 변환하는 함수
function formatSimpleDate(dateStr) {
if (!dateStr) return '날짜 없음';
if (typeof dateStr === 'string' && dateStr.includes('T')) {
return dateStr.split('T')[0]; // 2025-11-01T00:00:00.000Z → 2025-11-01
}
return dateStr;
}
// 현재 시간 업데이트
function updateTime() {
const now = new Date();
const timeString = now.toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// 시간 표시 요소가 있다면 업데이트
const timeElement = document.querySelector('.time-value');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// 전역 변수
let confirmedStartDate = null;
let confirmedEndDate = null;
let isAnalysisEnabled = false;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
// 서울 표준시(KST) 기준 날짜 설정
const today = getKSTDate();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); // 이번 달 마지막 날
document.getElementById('startDate').value = formatDateToString(monthStart);
document.getElementById('endDate').value = formatDateToString(monthEnd);
// 시간 업데이트 시작
updateTime();
setInterval(updateTime, 1000);
});
// 기간 확정 함수
function confirmPeriod() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
if (new Date(startDate) > new Date(endDate)) {
alert('시작일이 종료일보다 늦을 수 없습니다.');
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);
});
});
});
// 개별 분석 함수들
async function analyzeWorkStatus() {
if (!isAnalysisEnabled) {
alert('먼저 기간을 확정해주세요.');
return;
}
console.log('📊 기간별 작업 현황 분석 시작');
try {
// 기존 renderTimeSeriesChart 로직 사용
await renderTimeSeriesChart(confirmedStartDate, confirmedEndDate, '');
console.log('✅ 기간별 작업 현황 분석 완료');
} catch (error) {
console.error('❌ 기간별 작업 현황 분석 오류:', error);
alert('기간별 작업 현황 분석에 실패했습니다.');
}
}
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
});
const response = await apiCall(`/work-analysis/worker-stats?${params}`, 'GET');
renderWorkerPerformanceChart(response.data);
console.log('✅ 작업자별 성과 분석 완료');
} catch (error) {
console.error('❌ 작업자별 성과 분석 오류:', error);
alert('작업자별 성과 분석에 실패했습니다.');
}
}
// 오류 분석 함수
async function analyzeErrorAnalysis() {
console.log('⚠️ 오류 분석 시작');
if (!confirmedStartDate || !confirmedEndDate) {
alert('기간을 먼저 확정해주세요.');
return;
}
try {
// 병렬로 API 호출
const [recentWorkResponse, errorAnalysisResponse] = await Promise.all([
apiCall(`/work-analysis/recent-work?start=${confirmedStartDate}&end=${confirmedEndDate}&limit=2000`, 'GET'),
apiCall(`/work-analysis/error-analysis?start=${confirmedStartDate}&end=${confirmedEndDate}`, 'GET')
]);
console.log('🔍 최근 작업 API 응답:', recentWorkResponse);
console.log('🔍 오류 분석 API 응답:', errorAnalysisResponse);
if (recentWorkResponse.success && recentWorkResponse.data) {
renderErrorAnalysisTable(recentWorkResponse.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 isWeekendDate(dateString) {
const date = new Date(dateString);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // 일요일(0) 또는 토요일(6)
}
function isVacationProject(projectName) {
if (!projectName) return false;
const vacationKeywords = ['연차', '휴무', '휴가', '병가', '특별휴가'];
return vacationKeywords.some(keyword => projectName.includes(keyword));
}
// 작업 형태별 오류 데이터 집계 함수
function aggregateErrorData(recentWorkData) {
const workTypeMap = new Map();
let vacationData = null; // 연차/휴무 통합 데이터
recentWorkData.forEach(work => {
const isWeekend = isWeekendDate(work.report_date);
const isVacation = isVacationProject(work.project_name);
// 주말 연차는 완전히 제외
if (isWeekend && isVacation) {
console.log('🏖️ 주말 연차/휴무 제외:', work.report_date, work.project_name);
return;
}
if (isVacation) {
// 모든 연차/휴무를 하나로 통합
if (!vacationData) {
vacationData = {
project_id: 'vacation',
project_name: '연차/휴무',
job_no: null,
work_type_id: 'vacation',
work_type_name: '연차/휴무',
regularHours: 0,
errorHours: 0,
errorDetails: new Map(),
isVacation: true
};
}
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';
}
}
// 주말 체크 함수
function isWeekend(dateStr) {
const date = new Date(dateStr);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // 0: 일요일, 6: 토요일
}
// 연차/휴무 프로젝트 체크 함수
function isVacationProject(projectName) {
return projectName && (projectName.includes('연차') || projectName.includes('휴무'));
}
// 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());
// 기본 통계 조회
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
});
// 추가 데이터 로드 및 차트 렌더링
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 상태 관리
function showLoading() {
document.getElementById('emptyState').style.display = 'none';
document.getElementById('resultsGrid').style.display = 'none';
document.getElementById('chartsContainer').style.display = 'none';
document.getElementById('dataTableContainer').style.display = 'none';
document.getElementById('loadingState').style.display = 'flex';
}
function showResults() {
document.getElementById('loadingState').style.display = 'none';
document.getElementById('emptyState').style.display = 'none';
document.getElementById('resultsGrid').style.display = 'grid';
document.getElementById('chartsContainer').style.display = 'block';
document.getElementById('dataTableContainer').style.display = 'block';
}
function showEmpty() {
document.getElementById('loadingState').style.display = 'none';
document.getElementById('resultsGrid').style.display = 'none';
document.getElementById('chartsContainer').style.display = 'none';
document.getElementById('dataTableContainer').style.display = 'none';
document.getElementById('emptyState').style.display = 'flex';
}
// 차트 데이터 로드 및 렌더링
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;
// 주말 체크 함수
function isWeekend(dateStr) {
const date = new Date(dateStr);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // 0=일요일, 6=토요일
}
// 연차/휴무 프로젝트 체크 함수
function isVacationProject(projectName) {
return projectName && (
projectName.includes('연차') ||
projectName.includes('휴무') ||
projectName.toLowerCase().includes('vacation') ||
projectName.toLowerCase().includes('holiday')
);
}
// 상세 데이터에서 작업자별 실제 작업일수 계산 (주말 연차/휴무 제외)
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>
</body>
</html>