feat: 데이터베이스 및 웹 UI 대규모 리팩토링

- 삭제된 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: 삭제된 페이지 목록
This commit is contained in:
Hyungi Ahn
2025-11-03 09:26:50 +09:00
parent 2a3feca45b
commit 94ecc7333d
71 changed files with 15664 additions and 4385 deletions

View File

@@ -0,0 +1,672 @@
<!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://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.project-card {
transition: all 0.3s ease;
border-left: 4px solid #3B82F6;
}
.project-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
.work-type-row {
transition: background-color 0.2s ease;
}
.work-type-row:hover {
background-color: #f8fafc;
}
.error-high { border-left-color: #EF4444; }
.error-medium { border-left-color: #F59E0B; }
.error-low { border-left-color: #10B981; }
.progress-bar {
transition: width 0.8s ease-in-out;
}
.loading-spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stat-card.error {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stat-card.regular {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-card.total {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 헤더 -->
<header class="bg-white shadow-lg border-b">
<div class="container mx-auto px-6 py-4">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-800">🏗️ 프로젝트별 작업 시간 분석</h1>
<p class="text-gray-600 mt-1">총시간 · 정규시간 · 에러시간 상세 분석</p>
<div class="mt-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
🔧 시스템 관리자 전용
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-sm text-gray-500">
<span id="last-updated">마지막 업데이트: -</span>
</div>
<button id="refresh-btn" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors">
🔄 새로고침
</button>
<button onclick="history.back()" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors">
← 뒤로가기
</button>
</div>
</div>
</div>
</header>
<!-- 날짜 선택 -->
<div class="container mx-auto px-6 py-6">
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center space-x-2">
<label for="start-date" class="text-sm font-medium text-gray-700">시작일:</label>
<input type="date" id="start-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div class="flex items-center space-x-2">
<label for="end-date" class="text-sm font-medium text-gray-700">종료일:</label>
<input type="date" id="end-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<button id="analyze-btn" class="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-md text-sm font-medium transition-colors">
📊 분석 실행
</button>
<div class="flex items-center space-x-2">
<button id="preset-week" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1주일</button>
<button id="preset-month" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1개월</button>
<button id="preset-august" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">8월 전체</button>
</div>
</div>
</div>
</div>
<!-- 로딩 화면 -->
<div id="loading" class="hidden fixed inset-0 bg-white bg-opacity-90 flex items-center justify-center z-50">
<div class="text-center">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-600">데이터를 분석하는 중...</p>
</div>
</div>
<!-- 메인 컨테이너 -->
<div class="container mx-auto px-6 pb-8" id="main-content">
<!-- 전체 요약 통계 -->
<div id="summary-stats" class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8 hidden">
<div class="stat-card total rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="total-hours">-</div>
<div class="text-sm opacity-90">총 작업시간</div>
</div>
<div class="stat-card regular rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="regular-hours">-</div>
<div class="text-sm opacity-90">정규 시간</div>
</div>
<div class="stat-card error rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="error-hours">-</div>
<div class="text-sm opacity-90">에러 시간</div>
</div>
<div class="stat-card rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="error-rate">-</div>
<div class="text-sm opacity-90">전체 에러율</div>
</div>
</div>
<!-- 차트 섹션 -->
<div id="charts-section" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8 hidden">
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold mb-4">프로젝트별 시간 분포</h3>
<canvas id="project-chart"></canvas>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold mb-4">에러율 분석</h3>
<canvas id="error-chart"></canvas>
</div>
</div>
<!-- 프로젝트별 상세 데이터 -->
<div id="projects-container" class="space-y-6">
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 데이터 없음 메시지 -->
<div id="no-data" class="hidden text-center py-12">
<div class="text-gray-400 text-6xl mb-4">📊</div>
<h3 class="text-xl font-semibold text-gray-600 mb-2">분석할 데이터가 없습니다</h3>
<p class="text-gray-500">날짜 범위를 선택하고 분석을 실행해주세요.</p>
</div>
</div>
<!-- 기존 인증 시스템 사용 -->
<script>
// 전역 변수
let analysisData = null;
let charts = {};
// API 호출 함수 (토큰 포함)
async function apiCall(endpoint, options = {}) {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('인증 토큰이 없습니다.');
}
const defaultHeaders = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
const response = await fetch(`http://localhost:20005/api${endpoint}`, {
...options,
headers: {
...defaultHeaders,
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
}
// DOM 요소들
const elements = {
startDate: document.getElementById('start-date'),
endDate: document.getElementById('end-date'),
analyzeBtn: document.getElementById('analyze-btn'),
refreshBtn: document.getElementById('refresh-btn'),
loading: document.getElementById('loading'),
mainContent: document.getElementById('main-content'),
summaryStats: document.getElementById('summary-stats'),
chartsSection: document.getElementById('charts-section'),
projectsContainer: document.getElementById('projects-container'),
noData: document.getElementById('no-data'),
lastUpdated: document.getElementById('last-updated'),
presetWeek: document.getElementById('preset-week'),
presetMonth: document.getElementById('preset-month'),
presetAugust: document.getElementById('preset-august')
};
// 초기화
document.addEventListener('DOMContentLoaded', function() {
// 로그인 확인
const token = localStorage.getItem('token');
if (!token) {
alert('로그인이 필요합니다.');
window.location.href = '/index.html';
return;
}
// 사용자 정보 및 권한 확인
const userStr = localStorage.getItem('user');
if (!userStr) {
alert('사용자 정보를 찾을 수 없습니다.');
window.location.href = '/index.html';
return;
}
const user = JSON.parse(userStr);
// 시스템 권한 확인 (system 역할만 접근 가능)
if (user.role !== 'system') {
alert('시스템 관리자 권한이 필요합니다.');
window.location.href = '/pages/dashboard/user.html'; // 일반 사용자 대시보드로 리디렉션
return;
}
console.log('시스템 관리자 인증 완료:', user.name || user.username);
initializeDateInputs();
bindEventListeners();
// 8월 전체를 기본값으로 설정
setDatePreset('august');
});
// 날짜 입력 초기화
function initializeDateInputs() {
const today = new Date();
const oneMonthAgo = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
elements.endDate.value = today.toISOString().split('T')[0];
elements.startDate.value = oneMonthAgo.toISOString().split('T')[0];
}
// 이벤트 리스너 바인딩
function bindEventListeners() {
elements.analyzeBtn.addEventListener('click', performAnalysis);
elements.refreshBtn.addEventListener('click', performAnalysis);
// 날짜 프리셋 버튼들
elements.presetWeek.addEventListener('click', () => setDatePreset('week'));
elements.presetMonth.addEventListener('click', () => setDatePreset('month'));
elements.presetAugust.addEventListener('click', () => setDatePreset('august'));
// Enter 키로 분석 실행
elements.startDate.addEventListener('keypress', handleEnterKey);
elements.endDate.addEventListener('keypress', handleEnterKey);
}
// Enter 키 처리
function handleEnterKey(event) {
if (event.key === 'Enter') {
performAnalysis();
}
}
// 날짜 프리셋 설정
function setDatePreset(preset) {
const today = new Date();
let startDate, endDate;
switch (preset) {
case 'week':
startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7);
endDate = today;
break;
case 'month':
startDate = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
endDate = today;
break;
case 'august':
startDate = new Date(2025, 7, 1); // 2025년 8월 1일
endDate = new Date(2025, 7, 31); // 2025년 8월 31일
break;
}
elements.startDate.value = startDate.toISOString().split('T')[0];
elements.endDate.value = endDate.toISOString().split('T')[0];
}
// 분석 실행
async function performAnalysis() {
const startDate = elements.startDate.value;
const endDate = elements.endDate.value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
if (new Date(startDate) > new Date(endDate)) {
alert('시작일이 종료일보다 늦을 수 없습니다.');
return;
}
// 로그인 확인
const token = localStorage.getItem('token');
if (!token) {
alert('로그인이 필요합니다.');
window.location.href = '/index.html';
return;
}
showLoading(true);
try {
const response = await apiCall(`/work-analysis/project-worktype-analysis?start=${startDate}&end=${endDate}`);
const result = await response.json();
if (result.success) {
analysisData = result.data;
renderAnalysisResults();
updateLastUpdated();
} else {
throw new Error(result.error || '데이터 조회에 실패했습니다.');
}
} catch (error) {
console.error('분석 실패:', error);
showError(`분석 실패: ${error.message}`);
} finally {
showLoading(false);
}
}
// 로딩 표시/숨김
function showLoading(show) {
elements.loading.classList.toggle('hidden', !show);
}
// 에러 표시
function showError(message) {
alert(message);
}
// 분석 결과 렌더링
function renderAnalysisResults() {
if (!analysisData || !analysisData.projects || analysisData.projects.length === 0) {
showNoData();
return;
}
hideNoData();
renderSummaryStats();
renderCharts();
renderProjectCards();
}
// 데이터 없음 표시
function showNoData() {
elements.summaryStats.classList.add('hidden');
elements.chartsSection.classList.add('hidden');
elements.projectsContainer.innerHTML = '';
elements.noData.classList.remove('hidden');
}
// 데이터 없음 숨김
function hideNoData() {
elements.noData.classList.add('hidden');
elements.summaryStats.classList.remove('hidden');
elements.chartsSection.classList.remove('hidden');
}
// 요약 통계 렌더링
function renderSummaryStats() {
const summary = analysisData.summary;
document.getElementById('total-hours').textContent = `${(summary.grand_total_hours || 0).toFixed(1)}h`;
document.getElementById('regular-hours').textContent = `${(summary.grand_regular_hours || 0).toFixed(1)}h`;
document.getElementById('error-hours').textContent = `${(summary.grand_error_hours || 0).toFixed(1)}h`;
document.getElementById('error-rate').textContent = `${summary.grand_error_rate || 0}%`;
}
// 차트 렌더링
function renderCharts() {
renderProjectChart();
renderErrorChart();
}
// 프로젝트별 시간 분포 차트
function renderProjectChart() {
const ctx = document.getElementById('project-chart').getContext('2d');
// 기존 차트 삭제
if (charts.project) {
charts.project.destroy();
}
const projects = analysisData.projects;
const labels = projects.map(p => p.project_name || 'Unknown Project');
const totalHours = projects.map(p => p.total_project_hours || 0);
const regularHours = projects.map(p => p.total_regular_hours || 0);
const errorHours = projects.map(p => p.total_error_hours || 0);
charts.project = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: '정규 시간',
data: regularHours,
backgroundColor: '#10B981',
borderColor: '#059669',
borderWidth: 1
},
{
label: '에러 시간',
data: errorHours,
backgroundColor: '#EF4444',
borderColor: '#DC2626',
borderWidth: 1
}
]
},
options: {
responsive: true,
scales: {
x: {
stacked: true,
ticks: {
maxRotation: 45
}
},
y: {
stacked: true,
beginAtZero: true,
title: {
display: true,
text: '시간 (h)'
}
}
},
plugins: {
legend: {
position: 'top'
},
tooltip: {
callbacks: {
footer: function(tooltipItems) {
const index = tooltipItems[0].dataIndex;
const total = totalHours[index];
return `총 시간: ${total.toFixed(1)}h`;
}
}
}
}
}
});
}
// 에러율 분석 차트
function renderErrorChart() {
const ctx = document.getElementById('error-chart').getContext('2d');
// 기존 차트 삭제
if (charts.error) {
charts.error.destroy();
}
const projects = analysisData.projects;
const labels = projects.map(p => p.project_name || 'Unknown Project');
const errorRates = projects.map(p => p.project_error_rate || 0);
// 에러율에 따른 색상 결정
const colors = errorRates.map(rate => {
if (rate >= 10) return '#EF4444'; // 높음 (빨강)
if (rate >= 5) return '#F59E0B'; // 중간 (주황)
return '#10B981'; // 낮음 (초록)
});
charts.error = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: errorRates,
backgroundColor: colors,
borderColor: '#ffffff',
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
},
tooltip: {
callbacks: {
label: function(context) {
return `${context.label}: ${context.parsed}%`;
}
}
}
}
}
});
}
// 프로젝트 카드 렌더링
function renderProjectCards() {
const container = elements.projectsContainer;
container.innerHTML = '';
analysisData.projects.forEach(project => {
const card = createProjectCard(project);
container.appendChild(card);
});
}
// 프로젝트 카드 생성
function createProjectCard(project) {
const card = document.createElement('div');
// 에러율에 따른 카드 스타일 결정
let errorClass = 'error-low';
const errorRate = project.project_error_rate || 0;
if (errorRate >= 10) errorClass = 'error-high';
else if (errorRate >= 5) errorClass = 'error-medium';
card.className = `project-card ${errorClass} bg-white rounded-lg shadow-md p-6`;
// 프로젝트 헤더
const header = `
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-xl font-bold text-gray-800">${project.project_name || 'Unknown Project'}</h3>
<p class="text-sm text-gray-600">Job No: ${project.job_no || 'N/A'}</p>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-blue-600">${(project.total_project_hours || 0).toFixed(1)}h</div>
<div class="text-sm text-gray-500">총 시간</div>
</div>
</div>
`;
// 프로젝트 요약 통계
const summary = `
<div class="grid grid-cols-3 gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
<div class="text-center">
<div class="text-lg font-semibold text-green-600">${(project.total_regular_hours || 0).toFixed(1)}h</div>
<div class="text-xs text-gray-600">정규 시간</div>
</div>
<div class="text-center">
<div class="text-lg font-semibold text-red-600">${(project.total_error_hours || 0).toFixed(1)}h</div>
<div class="text-xs text-gray-600">에러 시간</div>
</div>
<div class="text-center">
<div class="text-lg font-semibold text-purple-600">${errorRate}%</div>
<div class="text-xs text-gray-600">에러율</div>
</div>
</div>
`;
// 작업 유형별 테이블
let workTypesTable = `
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">작업 유형</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">총 시간</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">정규 시간</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러 시간</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러율</th>
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">진행률</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
`;
if (project.work_types && project.work_types.length > 0) {
project.work_types.forEach(workType => {
const totalHours = workType.total_hours || 0;
const regularHours = workType.regular_hours || 0;
const errorHours = workType.error_hours || 0;
const errorRatePercent = workType.error_rate_percent || 0;
const regularPercent = totalHours > 0 ? (regularHours / totalHours) * 100 : 0;
const errorPercent = totalHours > 0 ? (errorHours / totalHours) * 100 : 0;
workTypesTable += `
<tr class="work-type-row">
<td class="px-4 py-3 text-sm font-medium text-gray-900">${workType.work_type_name || 'Unknown'}</td>
<td class="px-4 py-3 text-sm text-right font-semibold">${totalHours.toFixed(1)}h</td>
<td class="px-4 py-3 text-sm text-right text-green-600">${regularHours.toFixed(1)}h</td>
<td class="px-4 py-3 text-sm text-right text-red-600">${errorHours.toFixed(1)}h</td>
<td class="px-4 py-3 text-sm text-right font-medium ${errorRatePercent >= 10 ? 'text-red-600' : errorRatePercent >= 5 ? 'text-yellow-600' : 'text-green-600'}">${errorRatePercent}%</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full progress-bar" style="width: ${regularPercent}%"></div>
</div>
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-red-500 h-2 rounded-full progress-bar" style="width: ${errorPercent}%"></div>
</div>
</div>
</td>
</tr>
`;
});
} else {
workTypesTable += `
<tr>
<td colspan="6" class="px-4 py-3 text-center text-gray-500">작업 유형 데이터가 없습니다.</td>
</tr>
`;
}
workTypesTable += `
</tbody>
</table>
</div>
`;
card.innerHTML = header + summary + workTypesTable;
return card;
}
// 마지막 업데이트 시간 갱신
function updateLastUpdated() {
const now = new Date();
const timeString = now.toLocaleString('ko-KR');
elements.lastUpdated.textContent = `마지막 업데이트: ${timeString}`;
}
// 유틸리티 함수들
function formatNumber(num) {
return new Intl.NumberFormat('ko-KR').format(num);
}
function formatHours(hours) {
return `${hours.toFixed(1)}시간`;
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff