599 lines
19 KiB
JavaScript
599 lines
19 KiB
JavaScript
import { API, getAuthHeaders } from '/js/api-config.js';
|
|
|
|
// DOM 요소들
|
|
const startDateInput = document.getElementById('startDate');
|
|
const endDateInput = document.getElementById('endDate');
|
|
const analyzeBtn = document.getElementById('analyzeBtn');
|
|
const quickMonthBtn = document.getElementById('quickMonth');
|
|
const quickLastMonthBtn = document.getElementById('quickLastMonth');
|
|
|
|
const analysisCard = document.getElementById('analysisCard');
|
|
const summaryCards = document.getElementById('summaryCards');
|
|
|
|
// 필터 요소들
|
|
const projectFilter = document.getElementById('projectFilter');
|
|
const workerFilter = document.getElementById('workerFilter');
|
|
const taskFilter = document.getElementById('taskFilter');
|
|
const applyFilterBtn = document.getElementById('applyFilter');
|
|
|
|
// 탭 요소들
|
|
const tabButtons = document.querySelectorAll('.tab-button');
|
|
const tabContents = document.querySelectorAll('.analysis-content');
|
|
|
|
// 테이블 바디들
|
|
const projectTableBody = document.getElementById('projectTableBody');
|
|
const workerTableBody = document.getElementById('workerTableBody');
|
|
const taskTableBody = document.getElementById('taskTableBody');
|
|
const detailTableBody = document.getElementById('detailTableBody');
|
|
|
|
// 데이터 저장
|
|
let workers = [];
|
|
let projects = []; // 프로젝트 데이터 추가
|
|
let tasks = []; // 작업 데이터 추가
|
|
let rawData = [];
|
|
let filteredData = [];
|
|
|
|
// 초기화
|
|
async function initialize() {
|
|
console.log('프로젝트 분석 페이지 초기화 시작');
|
|
|
|
setDefaultDates();
|
|
console.log('기본 날짜 설정 완료');
|
|
|
|
await loadWorkers();
|
|
console.log('작업자 로딩 완료');
|
|
|
|
await loadProjects();
|
|
console.log('프로젝트 로딩 완료');
|
|
|
|
await loadTasks();
|
|
console.log('작업 로딩 완료');
|
|
|
|
setupEventListeners();
|
|
console.log('이벤트 리스너 설정 완료');
|
|
|
|
console.log('초기화 완료');
|
|
}
|
|
|
|
// 기본 날짜 설정 (이번 달)
|
|
function setDefaultDates() {
|
|
const now = new Date();
|
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
|
|
startDateInput.value = formatDate(firstDay);
|
|
endDateInput.value = formatDate(lastDay);
|
|
}
|
|
|
|
// 날짜 포맷 함수
|
|
function formatDate(date) {
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
// 작업자 데이터 로딩
|
|
async function loadWorkers() {
|
|
try {
|
|
console.log('API 주소:', API);
|
|
console.log('인증 헤더:', getAuthHeaders());
|
|
|
|
const res = await fetch(`${API}/workers`, { headers: getAuthHeaders() });
|
|
console.log('작업자 API 응답 상태:', res.status);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP 오류: ${res.status} ${res.statusText}`);
|
|
}
|
|
|
|
workers = await res.json();
|
|
console.log('불러온 작업자 데이터:', workers);
|
|
workers.sort((a, b) => a.worker_id - b.worker_id);
|
|
} catch (err) {
|
|
console.error('작업자 로딩 실패:', err);
|
|
alert(`작업자 데이터를 불러오는데 실패했습니다: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// 프로젝트 데이터 로딩
|
|
async function loadProjects() {
|
|
try {
|
|
const res = await fetch(`${API}/projects`, { headers: getAuthHeaders() });
|
|
console.log('프로젝트 API 응답 상태:', res.status);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP 오류: ${res.status} ${res.statusText}`);
|
|
}
|
|
|
|
projects = await res.json();
|
|
console.log('불러온 프로젝트 데이터:', projects);
|
|
} catch (err) {
|
|
console.error('프로젝트 로딩 실패:', err);
|
|
// 프로젝트 데이터가 없어도 일단 진행
|
|
projects = [];
|
|
}
|
|
}
|
|
|
|
// 작업 데이터 로딩
|
|
async function loadTasks() {
|
|
try {
|
|
const res = await fetch(`${API}/tasks`, { headers: getAuthHeaders() });
|
|
console.log('작업 API 응답 상태:', res.status);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP 오류: ${res.status} ${res.statusText}`);
|
|
}
|
|
|
|
tasks = await res.json();
|
|
console.log('불러온 작업 데이터:', tasks);
|
|
} catch (err) {
|
|
console.error('작업 로딩 실패:', err);
|
|
// 작업 데이터가 없어도 일단 진행
|
|
tasks = [];
|
|
}
|
|
}
|
|
|
|
// 이벤트 리스너 설정
|
|
function setupEventListeners() {
|
|
analyzeBtn.addEventListener('click', analyzeData);
|
|
quickMonthBtn.addEventListener('click', setThisMonth);
|
|
quickLastMonthBtn.addEventListener('click', setLastMonth);
|
|
applyFilterBtn.addEventListener('click', applyFilters);
|
|
|
|
// 탭 전환
|
|
tabButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
|
});
|
|
}
|
|
|
|
// 이번 달 설정
|
|
function setThisMonth() {
|
|
const now = new Date();
|
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
|
|
startDateInput.value = formatDate(firstDay);
|
|
endDateInput.value = formatDate(lastDay);
|
|
}
|
|
|
|
// 지난 달 설정
|
|
function setLastMonth() {
|
|
const now = new Date();
|
|
const firstDay = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
const lastDay = new Date(now.getFullYear(), now.getMonth(), 0);
|
|
|
|
startDateInput.value = formatDate(firstDay);
|
|
endDateInput.value = formatDate(lastDay);
|
|
}
|
|
|
|
// 데이터 분석 실행
|
|
async function analyzeData() {
|
|
const startDate = startDateInput.value;
|
|
const endDate = endDateInput.value;
|
|
|
|
console.log('분석 시작 - 선택된 기간:', startDate, '~', endDate);
|
|
|
|
if (!startDate || !endDate) {
|
|
alert('시작일과 종료일을 모두 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
if (startDate > endDate) {
|
|
alert('시작일이 종료일보다 늦을 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
showLoading();
|
|
|
|
try {
|
|
console.log('작업보고서 데이터 로딩 시작');
|
|
await loadWorkReports(startDate, endDate);
|
|
|
|
console.log('데이터 전처리 시작');
|
|
processData();
|
|
|
|
console.log('필터 업데이트 시작');
|
|
updateFilters();
|
|
|
|
console.log('요약 정보 렌더링 시작');
|
|
renderSummary();
|
|
|
|
console.log('테이블 렌더링 시작');
|
|
renderAllTables();
|
|
|
|
analysisCard.style.display = 'block';
|
|
console.log('분석 완료');
|
|
} catch (err) {
|
|
console.error('분석 실패:', err);
|
|
alert(`데이터 분석 중 오류가 발생했습니다: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// 실제 투입시간 계산 함수
|
|
function calculateActualWorkHours(workDetails, overtimeHours) {
|
|
let baseHours = 8; // 기본 8시간
|
|
|
|
// 근무형태에 따른 기본시간 조정
|
|
switch(workDetails) {
|
|
case '연차':
|
|
baseHours = 0;
|
|
break;
|
|
case '반차':
|
|
baseHours = 4;
|
|
break;
|
|
case '반반차':
|
|
baseHours = 6;
|
|
break;
|
|
case '조퇴':
|
|
baseHours = 2;
|
|
break;
|
|
case '휴무':
|
|
case '유급':
|
|
baseHours = 0;
|
|
break;
|
|
default:
|
|
baseHours = 8; // 정상근무
|
|
}
|
|
|
|
// 잔업시간 1.5배 가산
|
|
const overtimePay = (overtimeHours || 0) * 1.5;
|
|
|
|
return baseHours + overtimePay;
|
|
}
|
|
|
|
// 작업보고서 데이터 로딩
|
|
async function loadWorkReports(startDate, endDate) {
|
|
try {
|
|
const url = `${API}/workreports?start=${startDate}&end=${endDate}`;
|
|
console.log('작업보고서 요청 URL:', url);
|
|
console.log('요청 기간:', startDate, '~', endDate);
|
|
|
|
const res = await fetch(url, {
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
console.log('작업보고서 API 응답 상태:', res.status);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP 오류: ${res.status} ${res.statusText}`);
|
|
}
|
|
|
|
rawData = await res.json();
|
|
console.log('불러온 작업보고서 데이터 개수:', rawData.length);
|
|
console.log('작업보고서 데이터 샘플:', rawData.slice(0, 3));
|
|
|
|
// ID를 이름으로 매핑 + 실제 투입시간 계산
|
|
rawData = rawData.map(item => {
|
|
const worker = workers.find(w => w.worker_id === item.worker_id);
|
|
const project = projects.find(p => p.project_id === item.project_id);
|
|
const task = tasks.find(t => t.task_id === item.task_id);
|
|
|
|
// 실제 투입시간 계산
|
|
const actualHours = calculateActualWorkHours(item.work_details, item.overtime_hours);
|
|
|
|
return {
|
|
...item,
|
|
worker_name: worker ? worker.worker_name : '알 수 없음',
|
|
project_name: project ? project.project_name : `프로젝트 ID ${item.project_id}`,
|
|
task_category: task ? task.category : `작업 ID ${item.task_id}`,
|
|
work_hours: actualHours, // 계산된 실제 투입시간
|
|
base_hours: calculateActualWorkHours(item.work_details, 0), // 기본시간만
|
|
overtime_pay: (item.overtime_hours || 0) * 1.5 // 잔업 가산시간
|
|
};
|
|
});
|
|
|
|
console.log('ID 매핑 + 투입시간 계산 후 샘플:', rawData.slice(0, 3));
|
|
filteredData = [...rawData];
|
|
} catch (err) {
|
|
console.error('작업보고서 로딩 실패:', err);
|
|
throw new Error(`작업보고서 데이터 로딩 실패: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// 데이터 전처리
|
|
function processData() {
|
|
console.log('전처리 전 전체 데이터 개수:', rawData.length);
|
|
|
|
// 실제 투입시간이 있는 유효한 데이터만 필터링
|
|
filteredData = rawData.filter(item => {
|
|
const hasProject = item.project_name && item.project_name !== `프로젝트 ID ${item.project_id}`;
|
|
const hasTask = item.task_category && item.task_category !== `작업 ID ${item.task_id}`;
|
|
const hasActualHours = item.work_hours > 0; // 실제 투입시간이 0보다 큰 경우만
|
|
const isNotPureLeave = !['연차', '휴무', '유급'].includes(item.work_details); // 완전 휴가가 아닌 경우
|
|
|
|
if (!hasProject) console.log('프로젝트명 없음 또는 매핑 실패:', item);
|
|
if (!hasTask) console.log('작업 분류 없음 또는 매핑 실패:', item);
|
|
if (!hasActualHours) console.log('실제 투입시간 없음 (휴가/휴무):', item);
|
|
if (!isNotPureLeave) console.log('완전 휴가 데이터:', item);
|
|
|
|
return hasProject && hasTask && hasActualHours && isNotPureLeave;
|
|
});
|
|
|
|
console.log('전처리 후 유효 데이터 개수:', filteredData.length);
|
|
|
|
if (filteredData.length > 0) {
|
|
console.log('유효 데이터 샘플:', filteredData.slice(0, 3));
|
|
|
|
// 투입시간 계산 확인
|
|
const sampleItem = filteredData[0];
|
|
console.log('투입시간 계산 확인:', {
|
|
근무형태: sampleItem.work_details,
|
|
기본시간: sampleItem.base_hours,
|
|
잔업시간: sampleItem.overtime_hours,
|
|
잔업가산: sampleItem.overtime_pay,
|
|
총투입시간: sampleItem.work_hours,
|
|
'계산공식': `${sampleItem.base_hours} + ${sampleItem.overtime_pay} = ${sampleItem.work_hours}`
|
|
});
|
|
}
|
|
}
|
|
|
|
// 필터 옵션 업데이트
|
|
function updateFilters() {
|
|
// 프로젝트 필터
|
|
const projects = [...new Set(filteredData.map(item => item.project_name))].sort();
|
|
projectFilter.innerHTML = '<option value="">전체</option>';
|
|
projects.forEach(project => {
|
|
projectFilter.insertAdjacentHTML('beforeend', `<option value="${project}">${project}</option>`);
|
|
});
|
|
|
|
// 작업자 필터
|
|
const workerNames = [...new Set(filteredData.map(item => item.worker_name))].sort();
|
|
workerFilter.innerHTML = '<option value="">전체</option>';
|
|
workerNames.forEach(name => {
|
|
workerFilter.insertAdjacentHTML('beforeend', `<option value="${name}">${name}</option>`);
|
|
});
|
|
|
|
// 작업 분류 필터
|
|
const tasks = [...new Set(filteredData.map(item => item.task_category))].sort();
|
|
taskFilter.innerHTML = '<option value="">전체</option>';
|
|
tasks.forEach(task => {
|
|
taskFilter.insertAdjacentHTML('beforeend', `<option value="${task}">${task}</option>`);
|
|
});
|
|
}
|
|
|
|
// 필터 적용
|
|
function applyFilters() {
|
|
let filtered = [...rawData];
|
|
|
|
// 유효한 데이터만 필터링 (투입시간 계산 반영)
|
|
filtered = filtered.filter(item => {
|
|
const hasProject = item.project_name && item.project_name !== `프로젝트 ID ${item.project_id}`;
|
|
const hasTask = item.task_category && item.task_category !== `작업 ID ${item.task_id}`;
|
|
const hasActualHours = item.work_hours > 0;
|
|
const isNotPureLeave = !['연차', '휴무', '유급'].includes(item.work_details);
|
|
|
|
return hasProject && hasTask && hasActualHours && isNotPureLeave;
|
|
});
|
|
|
|
// 프로젝트 필터
|
|
if (projectFilter.value) {
|
|
filtered = filtered.filter(item => item.project_name === projectFilter.value);
|
|
}
|
|
|
|
// 작업자 필터
|
|
if (workerFilter.value) {
|
|
filtered = filtered.filter(item => item.worker_name === workerFilter.value);
|
|
}
|
|
|
|
// 작업 분류 필터
|
|
if (taskFilter.value) {
|
|
filtered = filtered.filter(item => item.task_category === taskFilter.value);
|
|
}
|
|
|
|
filteredData = filtered;
|
|
renderSummary();
|
|
renderAllTables();
|
|
}
|
|
|
|
// 요약 정보 렌더링
|
|
function renderSummary() {
|
|
const totalHours = filteredData.reduce((sum, item) => sum + parseFloat(item.work_hours || 0), 0);
|
|
const totalProjects = new Set(filteredData.map(item => item.project_name)).size;
|
|
const totalWorkers = new Set(filteredData.map(item => item.worker_name)).size;
|
|
const totalTasks = new Set(filteredData.map(item => item.task_category)).size;
|
|
|
|
summaryCards.innerHTML = `
|
|
<div class="summary-card">
|
|
<h4>총 투입 시간</h4>
|
|
<div class="value">${totalHours.toFixed(1)}h</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<h4>참여 프로젝트</h4>
|
|
<div class="value">${totalProjects}개</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<h4>참여 인원</h4>
|
|
<div class="value">${totalWorkers}명</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<h4>작업 분류</h4>
|
|
<div class="value">${totalTasks}개</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 모든 테이블 렌더링
|
|
function renderAllTables() {
|
|
renderProjectTable();
|
|
renderWorkerTable();
|
|
renderTaskTable();
|
|
renderDetailTable();
|
|
}
|
|
|
|
// 프로젝트별 테이블 렌더링
|
|
function renderProjectTable() {
|
|
const projectData = {};
|
|
|
|
filteredData.forEach(item => {
|
|
const project = item.project_name;
|
|
if (!projectData[project]) {
|
|
projectData[project] = {
|
|
name: project,
|
|
hours: 0,
|
|
workers: new Set()
|
|
};
|
|
}
|
|
projectData[project].hours += parseFloat(item.work_hours || 0);
|
|
projectData[project].workers.add(item.worker_name);
|
|
});
|
|
|
|
const sortedProjects = Object.values(projectData).sort((a, b) => b.hours - a.hours);
|
|
const totalHours = sortedProjects.reduce((sum, p) => sum + p.hours, 0);
|
|
|
|
let html = '';
|
|
if (sortedProjects.length === 0) {
|
|
html = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
|
|
} else {
|
|
sortedProjects.forEach((project, index) => {
|
|
const ratio = totalHours > 0 ? (project.hours / totalHours * 100).toFixed(1) : 0;
|
|
html += `
|
|
<tr>
|
|
<td>${index + 1}</td>
|
|
<td class="project-col" title="${project.name}">${project.name}</td>
|
|
<td class="hours-col">${project.hours.toFixed(1)}h</td>
|
|
<td>${ratio}%</td>
|
|
<td>${project.workers.size}명</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
}
|
|
|
|
projectTableBody.innerHTML = html;
|
|
}
|
|
|
|
// 작업자별 테이블 렌더링
|
|
function renderWorkerTable() {
|
|
const workerData = {};
|
|
|
|
filteredData.forEach(item => {
|
|
const worker = item.worker_name;
|
|
if (!workerData[worker]) {
|
|
workerData[worker] = {
|
|
name: worker,
|
|
hours: 0,
|
|
projects: new Set()
|
|
};
|
|
}
|
|
workerData[worker].hours += parseFloat(item.work_hours || 0);
|
|
workerData[worker].projects.add(item.project_name);
|
|
});
|
|
|
|
const sortedWorkers = Object.values(workerData).sort((a, b) => b.hours - a.hours);
|
|
const totalHours = sortedWorkers.reduce((sum, w) => sum + w.hours, 0);
|
|
|
|
let html = '';
|
|
if (sortedWorkers.length === 0) {
|
|
html = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
|
|
} else {
|
|
sortedWorkers.forEach((worker, index) => {
|
|
const ratio = totalHours > 0 ? (worker.hours / totalHours * 100).toFixed(1) : 0;
|
|
html += `
|
|
<tr>
|
|
<td>${index + 1}</td>
|
|
<td class="worker-col">${worker.name}</td>
|
|
<td class="hours-col">${worker.hours.toFixed(1)}h</td>
|
|
<td>${ratio}%</td>
|
|
<td>${worker.projects.size}개</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
}
|
|
|
|
workerTableBody.innerHTML = html;
|
|
}
|
|
|
|
// 작업별 테이블 렌더링
|
|
function renderTaskTable() {
|
|
const taskData = {};
|
|
|
|
filteredData.forEach(item => {
|
|
const task = item.task_category;
|
|
if (!taskData[task]) {
|
|
taskData[task] = {
|
|
name: task,
|
|
hours: 0,
|
|
workers: new Set()
|
|
};
|
|
}
|
|
taskData[task].hours += parseFloat(item.work_hours || 0);
|
|
taskData[task].workers.add(item.worker_name);
|
|
});
|
|
|
|
const sortedTasks = Object.values(taskData).sort((a, b) => b.hours - a.hours);
|
|
const totalHours = sortedTasks.reduce((sum, t) => sum + t.hours, 0);
|
|
|
|
let html = '';
|
|
if (sortedTasks.length === 0) {
|
|
html = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
|
|
} else {
|
|
sortedTasks.forEach((task, index) => {
|
|
const ratio = totalHours > 0 ? (task.hours / totalHours * 100).toFixed(1) : 0;
|
|
html += `
|
|
<tr>
|
|
<td>${index + 1}</td>
|
|
<td class="task-col" title="${task.name}">${task.name}</td>
|
|
<td class="hours-col">${task.hours.toFixed(1)}h</td>
|
|
<td>${ratio}%</td>
|
|
<td>${task.workers.size}명</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
}
|
|
|
|
taskTableBody.innerHTML = html;
|
|
}
|
|
|
|
// 상세 내역 테이블 렌더링
|
|
function renderDetailTable() {
|
|
const sortedData = [...filteredData].sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
let html = '';
|
|
if (sortedData.length === 0) {
|
|
html = '<tr><td colspan="8" class="no-data">데이터가 없습니다</td></tr>';
|
|
} else {
|
|
sortedData.forEach((item, index) => {
|
|
const date = new Date(item.date).toLocaleDateString('ko-KR');
|
|
const memo = item.memo || '-';
|
|
|
|
// 투입시간 계산 과정을 툴팁으로 표시
|
|
const hoursBreakdown = `기본: ${item.base_hours}h + 잔업가산: ${item.overtime_pay}h = 총 ${item.work_hours}h`;
|
|
const workDetailsDisplay = item.work_details || '정상근무';
|
|
|
|
html += `
|
|
<tr>
|
|
<td>${index + 1}</td>
|
|
<td>${date}</td>
|
|
<td class="project-col" title="${item.project_name}">${item.project_name}</td>
|
|
<td class="worker-col">${item.worker_name}</td>
|
|
<td class="task-col" title="${item.task_category}">${item.task_category}</td>
|
|
<td>${workDetailsDisplay}</td>
|
|
<td class="hours-col" title="${hoursBreakdown}">${parseFloat(item.work_hours || 0).toFixed(1)}h</td>
|
|
<td title="${memo}">${memo.length > 20 ? memo.substring(0, 20) + '...' : memo}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
}
|
|
|
|
detailTableBody.innerHTML = html;
|
|
}
|
|
|
|
// 탭 전환
|
|
function switchTab(tabName) {
|
|
// 모든 탭 버튼 비활성화
|
|
tabButtons.forEach(btn => btn.classList.remove('active'));
|
|
// 모든 탭 콘텐츠 숨기기
|
|
tabContents.forEach(content => content.classList.remove('active'));
|
|
|
|
// 선택된 탭 활성화
|
|
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
|
document.getElementById(`${tabName}Tab`).classList.add('active');
|
|
}
|
|
|
|
// 로딩 표시
|
|
function showLoading() {
|
|
const loadingHtml = '<tr><td colspan="5" class="loading">📊 데이터 분석 중...</td></tr>';
|
|
projectTableBody.innerHTML = loadingHtml;
|
|
workerTableBody.innerHTML = loadingHtml;
|
|
taskTableBody.innerHTML = loadingHtml;
|
|
detailTableBody.innerHTML = '<tr><td colspan="8" class="loading">📊 데이터 분석 중...</td></tr>';
|
|
}
|
|
|
|
// 초기화 실행
|
|
initialize(); |