모든 작업자가 개인 계정으로 로그인하여 본인의 연차와 출근 기록을 확인할 수 있는 시스템을 구축했습니다. 주요 기능: - 작업자-계정 1:1 통합 (기존 작업자 자동 계정 생성) - 연차 관리 시스템 (연도별 잔액 관리) - 출근 기록 시스템 (일일 근태 기록) - 나의 대시보드 페이지 (개인 정보 조회) 데이터베이스: - workers 테이블에 salary, base_annual_leave 컬럼 추가 - work_attendance_types, vacation_types 테이블 생성 - daily_attendance_records 테이블 생성 - worker_vacation_balance 테이블 생성 - 기존 작업자 자동 계정 생성 (username: 이름 기반) - Guest 역할 추가 백엔드 API: - 한글→영문 변환 유틸리티 (hangulToRoman.js) - UserRoutes에 개인 정보 조회 API 추가 - GET /api/users/me (내 정보) - GET /api/users/me/attendance-records (출근 기록) - GET /api/users/me/vacation-balance (연차 잔액) - GET /api/users/me/work-reports (작업 보고서) - GET /api/users/me/monthly-stats (월별 통계) 프론트엔드: - 나의 대시보드 페이지 (my-dashboard.html) - 연차 정보 위젯 (총/사용/잔여) - 월별 출근 캘린더 - 근무 시간 통계 - 최근 작업 보고서 목록 - 네비게이션 바에 "나의 대시보드" 메뉴 추가 배포 시 주의사항: - 마이그레이션 실행 필요 - 자동 생성된 계정 초기 비밀번호: 1234 - 작업자들에게 첫 로그인 후 비밀번호 변경 안내 필요 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2233 lines
105 KiB
HTML
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> |