- 삭제된 DB 테이블들과 관련 코드 정리: * 12개 사용하지 않는 테이블 삭제 (activity_logs, CuttingPlan, DailyIssueReports 등) * 관련 모델, 컨트롤러, 라우트 파일들 삭제 * index.js에서 삭제된 라우트들 제거 - 웹 UI 페이지 정리: * 21개 사용하지 않는 페이지 삭제 * issue-reports 폴더 전체 삭제 * 모든 사용자 권한을 그룹장 대시보드로 통일 - 데이터베이스 스키마 정리: * v1 스키마로 통일 (daily_work_reports 테이블) * JSON 데이터 임포트 스크립트 구현 * 외래키 관계 정리 및 데이터 일관성 확보 - 통합 Docker Compose 설정: * 모든 서비스를 단일 docker-compose.yml로 통합 * 20000번대 포트 유지 * JWT 시크릿 및 환경변수 설정 - 문서화: * DATABASE_SCHEMA.md: 현재 DB 스키마 문서화 * DELETED_TABLES.md: 삭제된 테이블 목록 * DELETED_PAGES.md: 삭제된 페이지 목록
1084 lines
41 KiB
HTML
1084 lines
41 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>작업 보고서 종합 분석 - TK 생산팀 포털</title>
|
|
<link rel="icon" href="data:,">
|
|
<link rel="stylesheet" href="../../css/main-layout.css">
|
|
<link rel="stylesheet" href="../../css/daily-work-report.css">
|
|
<style>
|
|
.analytics-container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.filter-section {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.filter-row {
|
|
display: flex;
|
|
gap: 15px;
|
|
align-items: end;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.filter-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 150px;
|
|
}
|
|
|
|
.filter-group label {
|
|
font-weight: 600;
|
|
margin-bottom: 5px;
|
|
color: #333;
|
|
}
|
|
|
|
.filter-group select,
|
|
.filter-group input {
|
|
padding: 8px 12px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.btn-analyze {
|
|
background: #007bff;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
height: fit-content;
|
|
}
|
|
|
|
.btn-analyze:hover {
|
|
background: #0056b3;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-card h3 {
|
|
margin: 0 0 10px 0;
|
|
color: #666;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.stat-card .value {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: #007bff;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.stat-card .unit {
|
|
font-size: 12px;
|
|
color: #888;
|
|
}
|
|
|
|
.chart-section {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.chart-section h2 {
|
|
margin: 0 0 20px 0;
|
|
color: #333;
|
|
border-bottom: 2px solid #007bff;
|
|
padding-bottom: 10px;
|
|
}
|
|
|
|
.chart-container {
|
|
height: 400px;
|
|
position: relative;
|
|
}
|
|
|
|
.table-container {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.analysis-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.analysis-table th,
|
|
.analysis-table td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.analysis-table th {
|
|
background: #f8f9fa;
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
|
|
.analysis-table tr:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: #e9ecef;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: #007bff;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.error-rate {
|
|
color: #dc3545;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #666;
|
|
}
|
|
|
|
.no-data {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #999;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid #ddd;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.tab {
|
|
padding: 12px 24px;
|
|
cursor: pointer;
|
|
border: none;
|
|
background: none;
|
|
color: #666;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.tab.active {
|
|
color: #007bff;
|
|
border-bottom: 2px solid #007bff;
|
|
}
|
|
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="analytics-container">
|
|
<h1>📊 작업 보고서 종합 분석</h1>
|
|
|
|
<!-- 필터 섹션 -->
|
|
<div class="filter-section">
|
|
<h2>🔍 분석 조건 설정</h2>
|
|
<div class="filter-row">
|
|
<div class="filter-group">
|
|
<label for="startDate">시작 날짜</label>
|
|
<input type="date" id="startDate" required>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label for="endDate">종료 날짜</label>
|
|
<input type="date" id="endDate" required>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label for="projectFilter">프로젝트</label>
|
|
<select id="projectFilter">
|
|
<option value="">전체 프로젝트</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label for="workerFilter">작업자</label>
|
|
<select id="workerFilter">
|
|
<option value="">전체 작업자</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn-analyze" onclick="performAnalysis()">분석 실행</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 로딩 상태 -->
|
|
<div id="loadingState" class="loading" style="display: none;">
|
|
<p>📊 데이터를 분석하고 있습니다...</p>
|
|
</div>
|
|
|
|
<!-- 분석 결과 -->
|
|
<div id="analysisResults" style="display: none;">
|
|
<!-- 전체 통계 -->
|
|
<div class="stats-grid" id="overallStats">
|
|
<!-- 통계 카드들이 동적으로 생성됩니다 -->
|
|
</div>
|
|
|
|
<!-- 탭 메뉴 -->
|
|
<div class="tabs">
|
|
<button class="tab active" onclick="showTab('daily')">일별 분석</button>
|
|
<button class="tab" onclick="showTab('worker')">작업자별 분석</button>
|
|
<button class="tab" onclick="showTab('project')">프로젝트별 분석</button>
|
|
<button class="tab" onclick="showTab('worktype')">작업유형별 분석</button>
|
|
<button class="tab" onclick="showTab('error')">에러 분석</button>
|
|
</div>
|
|
|
|
<!-- 일별 분석 -->
|
|
<div id="dailyTab" class="tab-content active">
|
|
<div class="chart-section">
|
|
<h2>📅 일별 작업 현황</h2>
|
|
<div class="analysis-insights" style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
|
|
<h4>💡 일별 분석 활용 가이드</h4>
|
|
<ul style="margin: 10px 0; padding-left: 20px; color: #666;">
|
|
<li><strong>작업량 패턴 파악:</strong> 주중/주말, 월초/월말 작업량 변화 확인</li>
|
|
<li><strong>생산성 트렌드:</strong> 평균선 대비 높은/낮은 날 분석으로 효율성 개선점 도출</li>
|
|
<li><strong>리소스 계획:</strong> 피크 시간대 파악으로 인력 배치 최적화</li>
|
|
<li><strong>품질 관리:</strong> 작업량이 급증한 날의 에러율 상관관계 분석</li>
|
|
</ul>
|
|
</div>
|
|
<div class="chart-container">
|
|
<canvas id="dailyChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 작업자별 분석 -->
|
|
<div id="workerTab" class="tab-content">
|
|
<div class="table-container">
|
|
<h2>👥 작업자별 성과 분석</h2>
|
|
<table class="analysis-table" id="workerTable">
|
|
<thead>
|
|
<tr>
|
|
<th>작업자</th>
|
|
<th>총 작업시간</th>
|
|
<th>작업 항목</th>
|
|
<th>근무일수</th>
|
|
<th>평균 시간/항목</th>
|
|
<th>에러율</th>
|
|
<th>진행률</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- 데이터가 동적으로 생성됩니다 -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 프로젝트별 분석 -->
|
|
<div id="projectTab" class="tab-content">
|
|
<div class="table-container">
|
|
<h2>📋 프로젝트별 진행 분석</h2>
|
|
<table class="analysis-table" id="projectTable">
|
|
<thead>
|
|
<tr>
|
|
<th>프로젝트</th>
|
|
<th>총 작업시간</th>
|
|
<th>작업 항목</th>
|
|
<th>참여 작업자</th>
|
|
<th>활동일수</th>
|
|
<th>에러율</th>
|
|
<th>진행률</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- 데이터가 동적으로 생성됩니다 -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 작업유형별 분석 -->
|
|
<div id="worktypeTab" class="tab-content">
|
|
<div class="chart-section">
|
|
<h2>🔧 작업 유형별 분포</h2>
|
|
<div class="chart-container">
|
|
<canvas id="worktypeChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 에러 분석 -->
|
|
<div id="errorTab" class="tab-content">
|
|
<div class="chart-section">
|
|
<h2>⚠️ 에러 유형별 분석</h2>
|
|
<div class="analysis-insights" style="background: #fff3cd; padding: 15px; border-radius: 8px; margin-bottom: 20px; border-left: 4px solid #ffc107;">
|
|
<h4>🔍 에러 분석 활용 가이드</h4>
|
|
<ul style="margin: 10px 0; padding-left: 20px; color: #856404;">
|
|
<li><strong>품질 개선:</strong> 가장 빈번한 에러 유형 우선 해결</li>
|
|
<li><strong>교육 계획:</strong> 특정 에러가 많은 작업자 대상 맞춤 교육</li>
|
|
<li><strong>프로세스 개선:</strong> 에러 패턴 분석으로 작업 절차 최적화</li>
|
|
<li><strong>예방 조치:</strong> 에러 발생 시간대 분석으로 예방책 수립</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- 에러 분포 차트 -->
|
|
<div class="chart-container" style="margin-bottom: 30px;">
|
|
<canvas id="errorChart"></canvas>
|
|
</div>
|
|
|
|
<!-- 에러 상세 테이블 -->
|
|
<div class="table-container">
|
|
<h3>📋 에러 상세 분석</h3>
|
|
<table class="analysis-table" id="errorTable">
|
|
<thead>
|
|
<tr>
|
|
<th>에러 유형</th>
|
|
<th>발생 횟수</th>
|
|
<th>에러 시간</th>
|
|
<th>비율 (%)</th>
|
|
<th>영향받은 작업자</th>
|
|
<th>영향받은 프로젝트</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- 데이터가 동적으로 생성됩니다 -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 데이터 없음 상태 -->
|
|
<div id="noDataState" class="no-data" style="display: none;">
|
|
<p>📭 선택한 조건에 해당하는 데이터가 없습니다.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chart.js 라이브러리 -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
|
|
|
|
<script>
|
|
// 전역 변수
|
|
let currentAnalysisData = null;
|
|
let dailyChart = null;
|
|
let worktypeChart = null;
|
|
let errorChart = null;
|
|
|
|
// 페이지 로드 시 초기화
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initializePage();
|
|
});
|
|
|
|
// 권한 체크 함수
|
|
function checkUserPermission() {
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
|
const adminRoles = ['admin', 'system'];
|
|
|
|
if (!user.role || !adminRoles.includes(user.role)) {
|
|
// 권한 없음 메시지 표시
|
|
document.body.innerHTML = `
|
|
<div style="display: flex; justify-content: center; align-items: center; height: 100vh; flex-direction: column; font-family: Arial, sans-serif;">
|
|
<div style="text-align: center; padding: 2rem; border: 2px solid #ff6b6b; border-radius: 10px; background: #fff5f5;">
|
|
<h2 style="color: #e74c3c; margin-bottom: 1rem;">🚫 접근 권한 없음</h2>
|
|
<p style="color: #666; margin-bottom: 1.5rem;">
|
|
작업보고서 분석 페이지는 <strong>관리자 권한</strong>이 필요합니다.<br>
|
|
현재 권한: <span style="color: #e74c3c;">${user.role || '없음'}</span><br>
|
|
필요 권한: <span style="color: #27ae60;">admin 또는 system</span>
|
|
</p>
|
|
<button onclick="window.history.back()" style="
|
|
background: #3498db;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
margin-right: 10px;
|
|
">← 이전 페이지</button>
|
|
<button onclick="window.location.href='/pages/dashboard/main.html'" style="
|
|
background: #27ae60;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
">🏠 대시보드</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// 페이지 초기화
|
|
async function initializePage() {
|
|
try {
|
|
// 권한 체크
|
|
if (!checkUserPermission()) {
|
|
return;
|
|
}
|
|
|
|
// 기본 날짜 설정 (데이터가 있는 범위로 설정)
|
|
document.getElementById('startDate').value = '2025-06-02';
|
|
document.getElementById('endDate').value = '2025-09-01';
|
|
|
|
// 필터 옵션 로드
|
|
await loadFilterOptions();
|
|
|
|
} catch (error) {
|
|
console.error('페이지 초기화 오류:', error);
|
|
alert('페이지 초기화 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
|
|
// 필터 옵션 로드
|
|
async function loadFilterOptions() {
|
|
try {
|
|
const response = await fetch('http://localhost:20005/api/daily-work-reports-analysis/filters', {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('필터 데이터 로드 실패');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// 프로젝트 옵션 추가
|
|
const projectSelect = document.getElementById('projectFilter');
|
|
data.data.projects.forEach(project => {
|
|
const option = document.createElement('option');
|
|
option.value = project.project_id;
|
|
option.textContent = `${project.project_name}`;
|
|
projectSelect.appendChild(option);
|
|
});
|
|
|
|
// 작업자 옵션 추가
|
|
const workerSelect = document.getElementById('workerFilter');
|
|
data.data.workers.forEach(worker => {
|
|
const option = document.createElement('option');
|
|
option.value = worker.worker_id;
|
|
option.textContent = `${worker.worker_name}`;
|
|
workerSelect.appendChild(option);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('필터 옵션 로드 오류:', error);
|
|
}
|
|
}
|
|
|
|
// 분석 실행
|
|
async function performAnalysis() {
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
const projectId = document.getElementById('projectFilter').value;
|
|
const workerId = document.getElementById('workerFilter').value;
|
|
|
|
if (!startDate || !endDate) {
|
|
alert('시작 날짜와 종료 날짜를 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
// 로딩 상태 표시
|
|
showLoadingState();
|
|
|
|
try {
|
|
// API 호출
|
|
const params = new URLSearchParams({
|
|
start_date: startDate,
|
|
end_date: endDate
|
|
});
|
|
|
|
if (projectId) params.append('project_id', projectId);
|
|
if (workerId) params.append('worker_id', workerId);
|
|
|
|
const response = await fetch(`http://localhost:20005/api/daily-work-reports-analysis/period?${params}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('분석 데이터 조회 실패');
|
|
}
|
|
|
|
const data = await response.json();
|
|
currentAnalysisData = data;
|
|
|
|
// 결과 표시
|
|
displayAnalysisResults(data);
|
|
|
|
} catch (error) {
|
|
console.error('분석 실행 오류:', error);
|
|
alert('분석 실행 중 오류가 발생했습니다.');
|
|
hideLoadingState();
|
|
}
|
|
}
|
|
|
|
// 로딩 상태 표시
|
|
function showLoadingState() {
|
|
document.getElementById('loadingState').style.display = 'block';
|
|
document.getElementById('analysisResults').style.display = 'none';
|
|
document.getElementById('noDataState').style.display = 'none';
|
|
}
|
|
|
|
// 로딩 상태 숨김
|
|
function hideLoadingState() {
|
|
document.getElementById('loadingState').style.display = 'none';
|
|
}
|
|
|
|
// 분석 결과 표시
|
|
function displayAnalysisResults(data) {
|
|
hideLoadingState();
|
|
|
|
if (!data.data || !data.data.summary || data.data.summary.total_entries === 0) {
|
|
document.getElementById('noDataState').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('analysisResults').style.display = 'block';
|
|
|
|
// 전체 통계 표시
|
|
displayOverallStats(data.data.summary);
|
|
|
|
// 각 탭별 데이터 표시
|
|
displayDailyChart(data.data.dailyStats);
|
|
displayWorkerTable(data.data.workerAnalysis);
|
|
displayProjectTable(data.data.projectAnalysis);
|
|
// displayWorktypeChart(data.data.workTypeAnalysis); // TODO: 작업유형 차트 구현 필요
|
|
displayErrorChart(data.data.dailyErrorStats);
|
|
displayErrorTable(data.data.errorAnalysis);
|
|
}
|
|
|
|
// 전체 통계 표시
|
|
function displayOverallStats(stats) {
|
|
const container = document.getElementById('overallStats');
|
|
container.innerHTML = '';
|
|
|
|
const statCards = [
|
|
{ title: '총 작업 항목', value: stats.total_entries, unit: '개' },
|
|
{ title: '총 작업 시간', value: stats.total_hours, unit: '시간' },
|
|
{ title: '참여 작업자', value: stats.unique_workers, unit: '명' },
|
|
{ title: '진행 프로젝트', value: stats.unique_projects, unit: '개' },
|
|
{ title: '근무일수', value: stats.working_days, unit: '일' },
|
|
{ title: '평균 시간/항목', value: parseFloat(stats.avg_hours_per_entry || 0).toFixed(1), unit: '시간' },
|
|
{ title: '에러 항목', value: stats.error_entries, unit: '개' },
|
|
{ title: '기여자 수', value: stats.contributors, unit: '명' }
|
|
];
|
|
|
|
statCards.forEach(stat => {
|
|
const card = document.createElement('div');
|
|
card.className = 'stat-card';
|
|
card.innerHTML = `
|
|
<h3>${stat.title}</h3>
|
|
<div class="value">${stat.value}</div>
|
|
<div class="unit">${stat.unit}</div>
|
|
`;
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
// 일별 차트 표시
|
|
function displayDailyChart(dailyData) {
|
|
const ctx = document.getElementById('dailyChart').getContext('2d');
|
|
|
|
if (dailyChart) {
|
|
dailyChart.destroy();
|
|
}
|
|
|
|
const labels = dailyData.map(d => {
|
|
const date = new Date(d.report_date);
|
|
return `${date.getMonth() + 1}/${date.getDate()}`;
|
|
});
|
|
const hours = dailyData.map(d => d.daily_hours);
|
|
const entries = dailyData.map(d => d.daily_entries);
|
|
|
|
// 평균선 계산
|
|
const avgHours = hours.reduce((a, b) => a + b, 0) / hours.length;
|
|
|
|
dailyChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: '일별 작업시간',
|
|
data: hours,
|
|
borderColor: '#007bff',
|
|
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
|
tension: 0.4,
|
|
yAxisID: 'y',
|
|
fill: true,
|
|
pointRadius: 4,
|
|
pointHoverRadius: 6
|
|
}, {
|
|
label: '일별 작업항목',
|
|
data: entries,
|
|
borderColor: '#28a745',
|
|
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
|
tension: 0.4,
|
|
yAxisID: 'y1',
|
|
fill: true,
|
|
pointRadius: 4,
|
|
pointHoverRadius: 6
|
|
}, {
|
|
label: `평균 작업시간 (${avgHours.toFixed(1)}h)`,
|
|
data: Array(labels.length).fill(avgHours),
|
|
borderColor: '#ff6b6b',
|
|
borderWidth: 2,
|
|
borderDash: [5, 5],
|
|
fill: false,
|
|
pointRadius: 0,
|
|
yAxisID: 'y'
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: {
|
|
type: 'linear',
|
|
display: true,
|
|
position: 'left',
|
|
title: {
|
|
display: true,
|
|
text: '작업시간 (h)'
|
|
}
|
|
},
|
|
y1: {
|
|
type: 'linear',
|
|
display: true,
|
|
position: 'right',
|
|
title: {
|
|
display: true,
|
|
text: '작업항목 (개)'
|
|
},
|
|
grid: {
|
|
drawOnChartArea: false,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 작업자별 테이블 표시
|
|
function displayWorkerTable(workerData) {
|
|
const tbody = document.querySelector('#workerTable tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
const maxHours = Math.max(...workerData.map(w => w.total_hours));
|
|
|
|
workerData.forEach(worker => {
|
|
const row = document.createElement('tr');
|
|
const progressPercent = (worker.total_hours / maxHours) * 100;
|
|
|
|
row.innerHTML = `
|
|
<td>${worker.worker_name}</td>
|
|
<td>${worker.total_hours}h</td>
|
|
<td>${worker.total_entries}개</td>
|
|
<td>${worker.working_days}일</td>
|
|
<td>${parseFloat(worker.avg_hours_per_entry).toFixed(1)}h</td>
|
|
<td class="error-rate">${worker.error_rate}%</td>
|
|
<td>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: ${progressPercent}%"></div>
|
|
</div>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// 프로젝트별 테이블 표시
|
|
function displayProjectTable(projectData) {
|
|
const tbody = document.querySelector('#projectTable tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
const maxHours = Math.max(...projectData.map(p => p.total_hours));
|
|
|
|
projectData.forEach(project => {
|
|
const row = document.createElement('tr');
|
|
const progressPercent = (project.total_hours / maxHours) * 100;
|
|
|
|
row.innerHTML = `
|
|
<td>${project.project_name}</td>
|
|
<td>${project.total_hours}h</td>
|
|
<td>${project.total_entries}개</td>
|
|
<td>${project.workers_involved}명</td>
|
|
<td>${project.active_days}일</td>
|
|
<td class="error-rate">${project.error_rate}%</td>
|
|
<td>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: ${progressPercent}%"></div>
|
|
</div>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// 작업유형별 차트 표시
|
|
function displayWorktypeChart(worktypeData) {
|
|
const ctx = document.getElementById('worktypeChart').getContext('2d');
|
|
|
|
if (worktypeChart) {
|
|
worktypeChart.destroy();
|
|
}
|
|
|
|
const labels = worktypeData.map(w => w.work_type_name);
|
|
const hours = worktypeData.map(w => w.total_hours);
|
|
const colors = [
|
|
'#007bff', '#28a745', '#ffc107', '#dc3545', '#6f42c1',
|
|
'#fd7e14', '#20c997', '#e83e8c', '#6c757d', '#17a2b8'
|
|
];
|
|
|
|
worktypeChart = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
data: hours,
|
|
backgroundColor: colors.slice(0, labels.length),
|
|
borderWidth: 2,
|
|
borderColor: '#fff'
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'right'
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 에러 테이블 표시
|
|
function displayErrorTable(errorData) {
|
|
const tbody = document.querySelector('#errorTable tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
if (errorData.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td colspan="5" style="text-align: center; color: #28a745;">에러가 발생하지 않았습니다! 🎉</td>';
|
|
tbody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
errorData.forEach(error => {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>${error.error_type_name || '기타'}</td>
|
|
<td>${error.error_count}회</td>
|
|
<td>${error.error_hours}h</td>
|
|
<td>${error.affected_workers}명</td>
|
|
<td>${error.affected_projects}개</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// 에러 분석 차트 표시 (일별 추이)
|
|
function displayErrorChart(dailyErrorData) {
|
|
const ctx = document.getElementById('errorChart').getContext('2d');
|
|
|
|
if (errorChart) {
|
|
errorChart.destroy();
|
|
}
|
|
|
|
if (!dailyErrorData || dailyErrorData.length === 0) {
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
ctx.font = '16px Arial';
|
|
ctx.fillStyle = '#666';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('📭 에러 데이터가 없습니다', ctx.canvas.width / 2, ctx.canvas.height / 2);
|
|
return;
|
|
}
|
|
|
|
const labels = dailyErrorData.map(d => {
|
|
const date = new Date(d.report_date);
|
|
return `${date.getMonth() + 1}/${date.getDate()}`;
|
|
});
|
|
const errorCounts = dailyErrorData.map(d => d.daily_errors);
|
|
const errorRates = dailyErrorData.map(d => d.daily_error_rate);
|
|
|
|
// 평균 에러율 계산
|
|
const avgErrorRate = errorRates.reduce((a, b) => a + b, 0) / errorRates.length;
|
|
|
|
errorChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: '일별 에러 발생 건수',
|
|
data: errorCounts,
|
|
borderColor: '#ff6b6b',
|
|
backgroundColor: 'rgba(255, 107, 107, 0.1)',
|
|
tension: 0.4,
|
|
yAxisID: 'y',
|
|
fill: true,
|
|
pointRadius: 4,
|
|
pointHoverRadius: 6,
|
|
borderWidth: 3
|
|
}, {
|
|
label: '일별 에러율 (%)',
|
|
data: errorRates,
|
|
borderColor: '#ffa726',
|
|
backgroundColor: 'rgba(255, 167, 38, 0.1)',
|
|
tension: 0.4,
|
|
yAxisID: 'y1',
|
|
fill: true,
|
|
pointRadius: 4,
|
|
pointHoverRadius: 6,
|
|
borderWidth: 3
|
|
}, {
|
|
label: `평균 에러율 (${avgErrorRate.toFixed(1)}%)`,
|
|
data: Array(labels.length).fill(avgErrorRate),
|
|
borderColor: '#e91e63',
|
|
borderWidth: 2,
|
|
borderDash: [5, 5],
|
|
fill: false,
|
|
pointRadius: 0,
|
|
yAxisID: 'y1'
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: {
|
|
type: 'linear',
|
|
display: true,
|
|
position: 'left',
|
|
title: {
|
|
display: true,
|
|
text: '에러 발생 건수'
|
|
},
|
|
beginAtZero: true
|
|
},
|
|
y1: {
|
|
type: 'linear',
|
|
display: true,
|
|
position: 'right',
|
|
title: {
|
|
display: true,
|
|
text: '에러율 (%)'
|
|
},
|
|
beginAtZero: true,
|
|
grid: {
|
|
drawOnChartArea: false,
|
|
},
|
|
}
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'top',
|
|
labels: {
|
|
usePointStyle: true,
|
|
padding: 20
|
|
}
|
|
},
|
|
tooltip: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
callbacks: {
|
|
label: function(context) {
|
|
let label = context.dataset.label || '';
|
|
if (label) {
|
|
label += ': ';
|
|
}
|
|
if (context.dataset.yAxisID === 'y1') {
|
|
label += context.parsed.y + '%';
|
|
} else {
|
|
label += context.parsed.y + '건';
|
|
}
|
|
return label;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
interaction: {
|
|
mode: 'nearest',
|
|
axis: 'x',
|
|
intersect: false
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 에러 분석 테이블 표시
|
|
function displayErrorTable(errorData) {
|
|
const container = document.getElementById('errorTable');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = '';
|
|
|
|
if (!errorData || errorData.length === 0) {
|
|
container.innerHTML = '<p class="no-data">📭 에러 데이터가 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
const table = document.createElement('table');
|
|
table.className = 'analysis-table';
|
|
table.innerHTML = `
|
|
<thead>
|
|
<tr>
|
|
<th>에러 유형</th>
|
|
<th>발생 횟수</th>
|
|
<th>에러 시간</th>
|
|
<th>비율</th>
|
|
<th>진행률</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${errorData.map(error => `
|
|
<tr>
|
|
<td><strong>${error.error_type_name}</strong></td>
|
|
<td>${error.error_count}회</td>
|
|
<td>${parseFloat(error.error_hours || 0).toFixed(1)}시간</td>
|
|
<td><span class="error-rate">${error.error_percentage}%</span></td>
|
|
<td>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: ${error.error_percentage}%"></div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
`;
|
|
|
|
container.appendChild(table);
|
|
}
|
|
|
|
// 작업자별 분석 테이블 표시
|
|
function displayWorkerTable(workerData) {
|
|
const container = document.getElementById('workerTable');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = '';
|
|
|
|
if (!workerData || workerData.length === 0) {
|
|
container.innerHTML = '<p class="no-data">📭 작업자 데이터가 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
const table = document.createElement('table');
|
|
table.className = 'analysis-table';
|
|
table.innerHTML = `
|
|
<thead>
|
|
<tr>
|
|
<th>작업자</th>
|
|
<th>총 작업시간</th>
|
|
<th>작업 항목</th>
|
|
<th>근무일수</th>
|
|
<th>평균 시간/항목</th>
|
|
<th>에러율</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${workerData.map(worker => `
|
|
<tr>
|
|
<td><strong>${worker.worker_name}</strong></td>
|
|
<td>${parseFloat(worker.total_hours).toFixed(1)}시간</td>
|
|
<td>${worker.total_entries}개</td>
|
|
<td>${worker.working_days}일</td>
|
|
<td>${parseFloat(worker.avg_hours_per_entry).toFixed(1)}시간</td>
|
|
<td><span class="error-rate">${worker.error_rate || 0}%</span></td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
`;
|
|
|
|
container.appendChild(table);
|
|
}
|
|
|
|
// 프로젝트별 분석 테이블 표시
|
|
function displayProjectTable(projectData) {
|
|
const container = document.getElementById('projectTable');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = '';
|
|
|
|
if (!projectData || projectData.length === 0) {
|
|
container.innerHTML = '<p class="no-data">📭 프로젝트 데이터가 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
const table = document.createElement('table');
|
|
table.className = 'analysis-table';
|
|
table.innerHTML = `
|
|
<thead>
|
|
<tr>
|
|
<th>프로젝트</th>
|
|
<th>총 작업시간</th>
|
|
<th>작업 항목</th>
|
|
<th>활동일수</th>
|
|
<th>에러율</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${projectData.map(project => `
|
|
<tr>
|
|
<td><strong>${project.project_name}</strong></td>
|
|
<td>${parseFloat(project.total_hours).toFixed(1)}시간</td>
|
|
<td>${project.total_entries}개</td>
|
|
<td>${project.working_days}일</td>
|
|
<td><span class="error-rate">${project.error_rate || 0}%</span></td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
`;
|
|
|
|
container.appendChild(table);
|
|
}
|
|
|
|
// 탭 전환
|
|
function showTab(tabName) {
|
|
// 모든 탭 비활성화
|
|
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
|
|
|
// 선택된 탭 활성화
|
|
event.target.classList.add('active');
|
|
document.getElementById(tabName + 'Tab').classList.add('active');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|