689 lines
22 KiB
HTML
689 lines
22 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>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
color: #333;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.header {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 15px;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
|
margin-bottom: 30px;
|
|
text-align: center;
|
|
}
|
|
|
|
.header h1 {
|
|
color: #4a5568;
|
|
font-size: 2.5em;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.header p {
|
|
color: #718096;
|
|
font-size: 1.1em;
|
|
}
|
|
|
|
.controls {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
|
margin-bottom: 30px;
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.control-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.control-group label {
|
|
font-weight: 600;
|
|
color: #4a5568;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.control-group input, .control-group select {
|
|
padding: 10px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
font-size: 1em;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.control-group input:focus, .control-group select:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
}
|
|
|
|
.btn {
|
|
padding: 12px 25px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 1em;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
|
}
|
|
|
|
.btn:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
|
}
|
|
|
|
.btn:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: white;
|
|
padding: 25px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
|
text-align: center;
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
.stat-card:hover {
|
|
transform: translateY(-5px);
|
|
}
|
|
|
|
.stat-card h3 {
|
|
color: #718096;
|
|
font-size: 0.9em;
|
|
margin-bottom: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.stat-card .value {
|
|
font-size: 2.5em;
|
|
font-weight: 700;
|
|
color: #667eea;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.stat-card .unit {
|
|
color: #a0aec0;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.charts-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
|
gap: 30px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.chart-card {
|
|
background: white;
|
|
padding: 25px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.chart-card h3 {
|
|
color: #4a5568;
|
|
margin-bottom: 20px;
|
|
font-size: 1.3em;
|
|
text-align: center;
|
|
}
|
|
|
|
.chart-container {
|
|
position: relative;
|
|
height: 300px;
|
|
}
|
|
|
|
.data-table {
|
|
background: white;
|
|
padding: 25px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.data-table h3 {
|
|
color: #4a5568;
|
|
margin-bottom: 20px;
|
|
font-size: 1.3em;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
th, td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
th {
|
|
background: #f7fafc;
|
|
color: #4a5568;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
font-size: 0.85em;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
tr:hover {
|
|
background: #f7fafc;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #718096;
|
|
}
|
|
|
|
.error {
|
|
background: #fed7d7;
|
|
color: #c53030;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
border: 1px solid #feb2b2;
|
|
}
|
|
|
|
.success {
|
|
background: #c6f6d5;
|
|
color: #2d7d32;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
border: 1px solid #9ae6b4;
|
|
}
|
|
|
|
.status-indicator {
|
|
display: inline-block;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.status-online {
|
|
background: #48bb78;
|
|
}
|
|
|
|
.status-offline {
|
|
background: #f56565;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.controls {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.charts-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.chart-container {
|
|
height: 250px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>📊 작업 분석 대시보드</h1>
|
|
<p>실시간 작업 현황 및 통계 분석</p>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<div class="control-group">
|
|
<label for="apiUrl">API 서버 URL</label>
|
|
<input type="text" id="apiUrl" value="http://192.168.0.3:3005" placeholder="http://localhost:3005">
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label for="startDate">시작일</label>
|
|
<input type="date" id="startDate">
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label for="endDate">종료일</label>
|
|
<input type="date" id="endDate">
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label> </label>
|
|
<button class="btn" onclick="testConnection()">🔍 연결 테스트</button>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label> </label>
|
|
<button class="btn" onclick="loadData()">📈 데이터 로드</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="connectionStatus"></div>
|
|
|
|
<div class="stats-grid" id="statsGrid">
|
|
<div class="stat-card">
|
|
<h3>총 작업시간</h3>
|
|
<div class="value" id="totalHours">-</div>
|
|
<div class="unit">시간</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<h3>총 보고서</h3>
|
|
<div class="value" id="totalReports">-</div>
|
|
<div class="unit">건</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<h3>활성 프로젝트</h3>
|
|
<div class="value" id="activeProjects">-</div>
|
|
<div class="unit">개</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<h3>에러율</h3>
|
|
<div class="value" id="errorRate">-</div>
|
|
<div class="unit">%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="charts-grid">
|
|
<div class="chart-card">
|
|
<h3>📈 일별 작업시간 추이</h3>
|
|
<div class="chart-container">
|
|
<canvas id="dailyTrendChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-card">
|
|
<h3>👥 작업자별 통계</h3>
|
|
<div class="chart-container">
|
|
<canvas id="workerStatsChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-card">
|
|
<h3>📋 프로젝트별 통계</h3>
|
|
<div class="chart-container">
|
|
<canvas id="projectStatsChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-card">
|
|
<h3>🔧 작업유형별 통계</h3>
|
|
<div class="chart-container">
|
|
<canvas id="workTypeStatsChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="data-table">
|
|
<h3>🕐 최근 작업 현황</h3>
|
|
<div id="recentWorkTable">
|
|
<div class="loading">데이터를 불러오는 중...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// 초기 설정
|
|
const today = new Date();
|
|
const lastWeek = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
document.getElementById('startDate').value = lastWeek.toISOString().split('T')[0];
|
|
document.getElementById('endDate').value = today.toISOString().split('T')[0];
|
|
|
|
let charts = {};
|
|
|
|
// API 호출 함수
|
|
async function apiCall(endpoint, params = {}) {
|
|
const baseUrl = document.getElementById('apiUrl').value;
|
|
const queryString = new URLSearchParams(params).toString();
|
|
const url = `${baseUrl}/api/work-analysis/${endpoint}${queryString ? '?' + queryString : ''}`;
|
|
|
|
try {
|
|
console.log('API 호출:', url);
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error('API 호출 에러:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// 연결 테스트
|
|
async function testConnection() {
|
|
const statusDiv = document.getElementById('connectionStatus');
|
|
statusDiv.innerHTML = '<div class="loading">연결 테스트 중...</div>';
|
|
|
|
try {
|
|
const response = await apiCall('health');
|
|
statusDiv.innerHTML = `
|
|
<div class="success">
|
|
<span class="status-indicator status-online"></span>
|
|
연결 성공! 서버가 정상 작동 중입니다.
|
|
<br><small>응답: ${response.message}</small>
|
|
</div>
|
|
`;
|
|
} catch (error) {
|
|
statusDiv.innerHTML = `
|
|
<div class="error">
|
|
<span class="status-indicator status-offline"></span>
|
|
연결 실패: ${error.message}
|
|
<br><small>API URL과 서버 상태를 확인해주세요.</small>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// 데이터 로드
|
|
async function loadData() {
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
|
|
if (!startDate || !endDate) {
|
|
alert('시작일과 종료일을 모두 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
const params = { start: startDate, end: endDate };
|
|
|
|
try {
|
|
// 기본 통계 로드
|
|
await loadStats(params);
|
|
|
|
// 차트 데이터 로드
|
|
await loadCharts(params);
|
|
|
|
// 최근 작업 로드
|
|
await loadRecentWork(params);
|
|
|
|
} catch (error) {
|
|
console.error('데이터 로드 에러:', error);
|
|
document.getElementById('connectionStatus').innerHTML = `
|
|
<div class="error">
|
|
데이터 로드 실패: ${error.message}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// 기본 통계 로드
|
|
async function loadStats(params) {
|
|
const response = await apiCall('stats', params);
|
|
const stats = response.data;
|
|
|
|
document.getElementById('totalHours').textContent = stats.totalHours || 0;
|
|
document.getElementById('totalReports').textContent = stats.totalReports || 0;
|
|
document.getElementById('activeProjects').textContent = stats.activeProjects || 0;
|
|
document.getElementById('errorRate').textContent = (stats.errorRate || 0).toFixed(1);
|
|
}
|
|
|
|
// 차트 로드
|
|
async function loadCharts(params) {
|
|
// 일별 추이 차트
|
|
const dailyTrendResponse = await apiCall('daily-trend', params);
|
|
createDailyTrendChart(dailyTrendResponse.data);
|
|
|
|
// 작업자별 통계 차트
|
|
const workerStatsResponse = await apiCall('worker-stats', params);
|
|
createWorkerStatsChart(workerStatsResponse.data);
|
|
|
|
// 프로젝트별 통계 차트
|
|
const projectStatsResponse = await apiCall('project-stats', params);
|
|
createProjectStatsChart(projectStatsResponse.data);
|
|
|
|
// 작업유형별 통계 차트
|
|
const workTypeStatsResponse = await apiCall('worktype-stats', params);
|
|
createWorkTypeStatsChart(workTypeStatsResponse.data);
|
|
}
|
|
|
|
// 일별 추이 차트 생성
|
|
function createDailyTrendChart(data) {
|
|
const ctx = document.getElementById('dailyTrendChart').getContext('2d');
|
|
|
|
if (charts.dailyTrend) {
|
|
charts.dailyTrend.destroy();
|
|
}
|
|
|
|
charts.dailyTrend = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.map(item => item.date),
|
|
datasets: [{
|
|
label: '작업시간',
|
|
data: data.map(item => item.hours),
|
|
borderColor: '#667eea',
|
|
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
|
borderWidth: 3,
|
|
fill: true,
|
|
tension: 0.4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
title: {
|
|
display: true,
|
|
text: '시간'
|
|
}
|
|
}
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 작업자별 통계 차트 생성
|
|
function createWorkerStatsChart(data) {
|
|
const ctx = document.getElementById('workerStatsChart').getContext('2d');
|
|
|
|
if (charts.workerStats) {
|
|
charts.workerStats.destroy();
|
|
}
|
|
|
|
charts.workerStats = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.map(item => `작업자 ${item.worker_id}`),
|
|
datasets: [{
|
|
label: '총 작업시간',
|
|
data: data.map(item => item.totalHours),
|
|
backgroundColor: 'rgba(118, 75, 162, 0.8)',
|
|
borderColor: '#764ba2',
|
|
borderWidth: 1
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
title: {
|
|
display: true,
|
|
text: '시간'
|
|
}
|
|
}
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 프로젝트별 통계 차트 생성
|
|
function createProjectStatsChart(data) {
|
|
const ctx = document.getElementById('projectStatsChart').getContext('2d');
|
|
|
|
if (charts.projectStats) {
|
|
charts.projectStats.destroy();
|
|
}
|
|
|
|
charts.projectStats = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: data.map(item => `프로젝트 ${item.project_id}`),
|
|
datasets: [{
|
|
data: data.map(item => item.totalHours),
|
|
backgroundColor: [
|
|
'#667eea',
|
|
'#764ba2',
|
|
'#f093fb',
|
|
'#f5576c',
|
|
'#4ecdc4',
|
|
'#45b7d1',
|
|
'#96ceb4',
|
|
'#ffeaa7'
|
|
]
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom'
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 작업유형별 통계 차트 생성
|
|
function createWorkTypeStatsChart(data) {
|
|
const ctx = document.getElementById('workTypeStatsChart').getContext('2d');
|
|
|
|
if (charts.workTypeStats) {
|
|
charts.workTypeStats.destroy();
|
|
}
|
|
|
|
charts.workTypeStats = new Chart(ctx, {
|
|
type: 'pie',
|
|
data: {
|
|
labels: data.map(item => `작업유형 ${item.work_type_id}`),
|
|
datasets: [{
|
|
data: data.map(item => item.totalHours),
|
|
backgroundColor: [
|
|
'#ff6b6b',
|
|
'#4ecdc4',
|
|
'#45b7d1',
|
|
'#96ceb4',
|
|
'#ffeaa7',
|
|
'#dda0dd',
|
|
'#98d8c8',
|
|
'#f7dc6f'
|
|
]
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom'
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 최근 작업 로드
|
|
async function loadRecentWork(params) {
|
|
const response = await apiCall('recent-work', { ...params, limit: 10 });
|
|
const data = response.data;
|
|
|
|
const tableHtml = `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>날짜</th>
|
|
<th>작업자</th>
|
|
<th>프로젝트</th>
|
|
<th>작업유형</th>
|
|
<th>작업시간</th>
|
|
<th>상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${data.map(item => `
|
|
<tr>
|
|
<td>${item.report_date}</td>
|
|
<td>작업자 ${item.worker_id}</td>
|
|
<td>프로젝트 ${item.project_id}</td>
|
|
<td>유형 ${item.work_type_id}</td>
|
|
<td>${item.work_hours}시간</td>
|
|
<td>${item.error_type_id > 0 ? '❌ 에러' : '✅ 정상'}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
|
|
document.getElementById('recentWorkTable').innerHTML = tableHtml;
|
|
}
|
|
|
|
// 페이지 로드 시 연결 테스트
|
|
window.onload = function() {
|
|
testConnection();
|
|
};
|
|
</script>
|
|
</body>
|
|
</html> |