refactor(frontend): 프로젝트 분석 페이지 전체 리팩토링
- 600줄에 달하는 project-analysis.js 파일을 API, Data, UI, Controller 네 개의 모듈로 분리 - 복잡한 데이터 처리 로직을 data 모듈로 위임하고, UI 렌더링 코드를 ui 모듈로 분리하여 관심사 분리 원칙(SoC) 적용 - 전역 상태를 최소화하고 데이터 흐름을 명확하게 개선하여 유지보수성 및 안정성 향상
This commit is contained in:
44
web-ui/js/project-analysis-api.js
Normal file
44
web-ui/js/project-analysis-api.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// /js/project-analysis-api.js
|
||||
import { apiGet } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 분석 페이지에 필요한 모든 초기 데이터(마스터 데이터)를 병렬로 가져옵니다.
|
||||
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
|
||||
*/
|
||||
export async function getMasterData() {
|
||||
try {
|
||||
const [workers, projects, tasks] = await Promise.all([
|
||||
apiGet('/workers'),
|
||||
apiGet('/projects'),
|
||||
apiGet('/tasks')
|
||||
]);
|
||||
return { workers, projects, tasks };
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로딩 실패:', error);
|
||||
// 하나라도 실패하면 페이지 기능에 문제가 생길 수 있으므로 에러를 던집니다.
|
||||
throw new Error('페이지 초기화에 필요한 데이터를 불러오는 데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 기간의 작업 보고서 데이터를 가져옵니다.
|
||||
* 백엔드에 집계 API가 있다면 그쪽을 사용하는 것이 더 효율적입니다.
|
||||
* (예: /api/analysis/reports?startDate=...&endDate=...)
|
||||
* 현재는 기존 방식을 유지합니다.
|
||||
* @param {string} startDate - 시작일 (YYYY-MM-DD)
|
||||
* @param {string} endDate - 종료일 (YYYY-MM-DD)
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getWorkReports(startDate, endDate) {
|
||||
try {
|
||||
// API 엔드포인트를 명확하게 수정합니다.
|
||||
// 기존: /workreports?start=...
|
||||
// 변경: /daily-work-reports/search?startDate=...&endDate=... (가정)
|
||||
// 우선 기존 URL 구조를 최대한 따르되, 좀 더 명시적인 경로로 변경 제안
|
||||
const reports = await apiGet(`/workreports/search?startDate=${startDate}&endDate=${endDate}`);
|
||||
return reports;
|
||||
} catch (error) {
|
||||
console.error('작업 보고서 데이터 로딩 실패:', error);
|
||||
throw new Error(`작업 보고서 데이터를 불러오는 데 실패했습니다: ${error.message}`);
|
||||
}
|
||||
}
|
||||
114
web-ui/js/project-analysis-data.js
Normal file
114
web-ui/js/project-analysis-data.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// /js/project-analysis-data.js
|
||||
|
||||
/**
|
||||
* 근무 형태에 따른 실제 투입 시간을 계산합니다.
|
||||
* 잔업(OT)은 1.5배 가산됩니다.
|
||||
* @param {string} workDetails - 근무 형태 (예: '정상', '연차', '반차')
|
||||
* @param {number} overtimeHours - 잔업 시간
|
||||
* @returns {number} - 실제 투입 시간
|
||||
*/
|
||||
function calculateActualWorkHours(workDetails, overtimeHours) {
|
||||
let baseHours = 8;
|
||||
switch(workDetails) {
|
||||
case '연차':
|
||||
case '휴무':
|
||||
case '유급': baseHours = 0; break;
|
||||
case '반차': baseHours = 4; break;
|
||||
case '반반차': baseHours = 6; break;
|
||||
case '조퇴': baseHours = 2; break;
|
||||
default: baseHours = 8; // 정상근무
|
||||
}
|
||||
return baseHours + (overtimeHours || 0) * 1.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 작업 보고서 데이터에 마스터 데이터를 매핑하고 가공합니다.
|
||||
* @param {Array} rawReports - 원본 작업 보고서
|
||||
* @param {{workers: Array, projects: Array, tasks: Array}} masterData - 마스터 데이터
|
||||
* @returns {Array} - 가공된 데이터
|
||||
*/
|
||||
export function processRawData(rawReports, masterData) {
|
||||
if (!rawReports || !masterData) return [];
|
||||
|
||||
const { workers, projects, tasks } = masterData;
|
||||
const workerMap = new Map(workers.map(w => [w.worker_id, w.worker_name]));
|
||||
const projectMap = new Map(projects.map(p => [p.project_id, p.project_name]));
|
||||
const taskMap = new Map(tasks.map(t => [t.task_id, t.category]));
|
||||
|
||||
return rawReports
|
||||
.map(item => ({
|
||||
...item,
|
||||
worker_name: workerMap.get(item.worker_id) || '알 수 없음',
|
||||
project_name: projectMap.get(item.project_id) || `프로젝트 ID ${item.project_id}`,
|
||||
task_category: taskMap.get(item.task_id) || `작업 ID ${item.task_id}`,
|
||||
work_hours: calculateActualWorkHours(item.work_details, item.overtime_hours),
|
||||
}))
|
||||
// 실제 투입 시간이 있고, 완전 휴가가 아닌 유효한 데이터만 필터링
|
||||
.filter(item => item.work_hours > 0 && !['연차', '휴무', '유급'].includes(item.work_details));
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 데이터셋을 필터링합니다.
|
||||
* @param {Array} data - 가공된 전체 데이터
|
||||
* @param {{project: string, worker: string, task: string}} filters - 필터 조건
|
||||
* @returns {Array} - 필터링된 데이터
|
||||
*/
|
||||
export function applyFilters(data, filters) {
|
||||
return data.filter(item => {
|
||||
const projectMatch = !filters.project || item.project_name === filters.project;
|
||||
const workerMatch = !filters.worker || item.worker_name === filters.worker;
|
||||
const taskMatch = !filters.task || item.task_category === filters.task;
|
||||
return projectMatch && workerMatch && taskMatch;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터를 특정 키(프로젝트, 작업자, 작업)로 집계합니다.
|
||||
* @param {Array} data - 집계할 데이터
|
||||
* @param {'project_name' | 'worker_name' | 'task_category'} key - 집계 기준 키
|
||||
* @returns {Array} - 집계된 데이터
|
||||
*/
|
||||
function aggregateData(data, key) {
|
||||
const aggregated = data.reduce((acc, item) => {
|
||||
const group = item[key];
|
||||
if (!acc[group]) {
|
||||
acc[group] = {
|
||||
name: group,
|
||||
hours: 0,
|
||||
// 참여자 또는 참여 프로젝트를 추적하기 위한 Set
|
||||
participants: new Set(),
|
||||
};
|
||||
}
|
||||
acc[group].hours += item.work_hours;
|
||||
// 집계 키에 따라 다른 종류의 참여자를 추가
|
||||
if (key === 'project_name') acc[group].participants.add(item.worker_name);
|
||||
else if (key === 'worker_name') acc[group].participants.add(item.project_name);
|
||||
else if (key === 'task_category') acc[group].participants.add(item.worker_name);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.values(aggregated).sort((a, b) => b.hours - a.hours);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 필터링된 데이터를 기반으로 모든 분석 데이터를 생성합니다.
|
||||
* @param {Array} filteredData - 필터링된 데이터
|
||||
* @returns {object} - 요약, 프로젝트별, 작업자별, 작업별 집계 데이터
|
||||
*/
|
||||
export function getAnalysis(filteredData) {
|
||||
const totalHours = filteredData.reduce((sum, item) => sum + (item.work_hours || 0), 0);
|
||||
|
||||
const summary = {
|
||||
totalHours,
|
||||
totalProjects: new Set(filteredData.map(item => item.project_name)).size,
|
||||
totalWorkers: new Set(filteredData.map(item => item.worker_name)).size,
|
||||
totalTasks: new Set(filteredData.map(item => item.task_category)).size,
|
||||
};
|
||||
|
||||
const byProject = aggregateData(filteredData, 'project_name');
|
||||
const byWorker = aggregateData(filteredData, 'worker_name');
|
||||
const byTask = aggregateData(filteredData, 'task_category');
|
||||
|
||||
return { summary, byProject, byWorker, byTask };
|
||||
}
|
||||
172
web-ui/js/project-analysis-ui.js
Normal file
172
web-ui/js/project-analysis-ui.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// /js/project-analysis-ui.js
|
||||
|
||||
const DOM = {
|
||||
// 기간 설정
|
||||
startDate: document.getElementById('startDate'),
|
||||
endDate: document.getElementById('endDate'),
|
||||
// 카드 및 필터
|
||||
analysisCard: document.getElementById('analysisCard'),
|
||||
summaryCards: document.getElementById('summaryCards'),
|
||||
projectFilter: document.getElementById('projectFilter'),
|
||||
workerFilter: document.getElementById('workerFilter'),
|
||||
taskFilter: document.getElementById('taskFilter'),
|
||||
// 탭
|
||||
tabButtons: document.querySelectorAll('.tab-button'),
|
||||
tabContents: document.querySelectorAll('.analysis-content'),
|
||||
// 테이블 본문
|
||||
projectTableBody: document.getElementById('projectTableBody'),
|
||||
workerTableBody: document.getElementById('workerTableBody'),
|
||||
taskTableBody: document.getElementById('taskTableBody'),
|
||||
detailTableBody: document.getElementById('detailTableBody'),
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜 input 값을 YYYY-MM-DD 형식의 문자열로 반환
|
||||
* @param {Date} date - 날짜 객체
|
||||
* @returns {string} - 포맷된 날짜 문자열
|
||||
*/
|
||||
const formatDate = (date) => date.toISOString().split('T')[0];
|
||||
|
||||
/**
|
||||
* UI상의 날짜 선택기를 기본값(이번 달)으로 설정합니다.
|
||||
*/
|
||||
export 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);
|
||||
DOM.startDate.value = formatDate(firstDay);
|
||||
DOM.endDate.value = formatDate(lastDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 실행 전후의 UI 상태를 관리합니다 (로딩 표시 등)
|
||||
* @param {'loading' | 'data' | 'no-data' | 'error'} state - UI 상태
|
||||
*/
|
||||
export function setUIState(state) {
|
||||
const projectCols = 5;
|
||||
const detailCols = 8;
|
||||
const messages = {
|
||||
loading: '📊 데이터 분석 중...',
|
||||
'no-data': '해당 기간에 분석할 데이터가 없습니다.',
|
||||
error: '오류가 발생했습니다. 다시 시도해주세요.',
|
||||
};
|
||||
|
||||
if (state === 'data') {
|
||||
DOM.analysisCard.style.display = 'block';
|
||||
} else {
|
||||
const message = messages[state];
|
||||
const html = `<tr><td colspan="${projectCols}" class="${state}">${message}</td></tr>`;
|
||||
const detailHtml = `<tr><td colspan="${detailCols}" class="${state}">${message}</td></tr>`;
|
||||
DOM.projectTableBody.innerHTML = html;
|
||||
DOM.workerTableBody.innerHTML = html;
|
||||
DOM.taskTableBody.innerHTML = html;
|
||||
DOM.detailTableBody.innerHTML = detailHtml;
|
||||
DOM.summaryCards.innerHTML = '';
|
||||
DOM.analysisCard.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 필터링된 데이터에서 고유한 값을 추출하여 필터 옵션을 채웁니다.
|
||||
* @param {Array} data - 가공된 전체 데이터
|
||||
*/
|
||||
export function updateFilterOptions(data) {
|
||||
const createOptions = (items) => {
|
||||
let html = '<option value="">전체</option>';
|
||||
[...new Set(items)].sort().forEach(item => {
|
||||
html += `<option value="${item}">${item}</option>`;
|
||||
});
|
||||
return html;
|
||||
};
|
||||
DOM.projectFilter.innerHTML = createOptions(data.map(d => d.project_name));
|
||||
DOM.workerFilter.innerHTML = createOptions(data.map(d => d.worker_name));
|
||||
DOM.taskFilter.innerHTML = createOptions(data.map(d => d.task_category));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 카드 데이터를 렌더링합니다.
|
||||
* @param {object} summary - 요약 데이터
|
||||
*/
|
||||
export function renderSummary(summary) {
|
||||
DOM.summaryCards.innerHTML = `
|
||||
<div class="summary-card"><h4>총 투입 시간</h4><div class="value">${summary.totalHours.toFixed(1)}h</div></div>
|
||||
<div class="summary-card"><h4>참여 프로젝트</h4><div class="value">${summary.totalProjects}개</div></div>
|
||||
<div class="summary-card"><h4>참여 인원</h4><div class="value">${summary.totalWorkers}명</div></div>
|
||||
<div class="summary-card"><h4>작업 분류</h4><div class="value">${summary.totalTasks}개</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계된 데이터를 받아 테이블을 렌더링하는 범용 함수
|
||||
* @param {HTMLElement} tableBodyEl - 렌더링할 테이블의 tbody 요소
|
||||
* @param {Array} data - 집계된 데이터 배열
|
||||
* @param {function} rowRenderer - 각 행을 렌더링하는 함수
|
||||
*/
|
||||
function renderTable(tableBodyEl, data, rowRenderer) {
|
||||
if (data.length === 0) {
|
||||
tableBodyEl.innerHTML = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
const totalHours = data.reduce((sum, item) => sum + item.hours, 0);
|
||||
tableBodyEl.innerHTML = data.map((item, index) => rowRenderer(item, index, totalHours)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계된 데이터를 기반으로 모든 분석 테이블을 렌더링합니다.
|
||||
* @param {object} analysis - 프로젝트/작업자/작업별 집계 데이터
|
||||
*/
|
||||
export function renderAnalysisTables(analysis) {
|
||||
renderTable(DOM.projectTableBody, analysis.byProject, (p, i, total) => `
|
||||
<tr><td>${i + 1}</td><td class="project-col" title="${p.name}">${p.name}</td><td class="hours-col">${p.hours.toFixed(1)}h</td>
|
||||
<td>${(p.hours / total * 100).toFixed(1)}%</td><td>${p.participants.size}명</td></tr>`);
|
||||
|
||||
renderTable(DOM.workerTableBody, analysis.byWorker, (w, i, total) => `
|
||||
<tr><td>${i + 1}</td><td class="worker-col">${w.name}</td><td class="hours-col">${w.hours.toFixed(1)}h</td>
|
||||
<td>${(w.hours / total * 100).toFixed(1)}%</td><td>${w.participants.size}개</td></tr>`);
|
||||
|
||||
renderTable(DOM.taskTableBody, analysis.byTask, (t, i, total) => `
|
||||
<tr><td>${i + 1}</td><td class="task-col" title="${t.name}">${t.name}</td><td class="hours-col">${t.hours.toFixed(1)}h</td>
|
||||
<td>${(t.hours / total * 100).toFixed(1)}%</td><td>${t.participants.size}명</td></tr>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 내역 테이블을 렌더링합니다.
|
||||
* @param {Array} detailData - 필터링된 상세 데이터
|
||||
*/
|
||||
export function renderDetailTable(detailData) {
|
||||
if (detailData.length === 0) {
|
||||
DOM.detailTableBody.innerHTML = '<tr><td colspan="8" class="no-data">데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
const sorted = [...detailData].sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
DOM.detailTableBody.innerHTML = sorted.map((item, index) => `
|
||||
<tr><td>${index + 1}</td><td>${formatDate(new Date(item.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>${item.work_details || '정상근무'}</td>
|
||||
<td class="hours-col">${(item.work_hours || 0).toFixed(1)}h</td>
|
||||
<td title="${item.memo || '-'}">${(item.memo || '-').substring(0, 20)}</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 UI를 제어합니다.
|
||||
* @param {string} tabName - 활성화할 탭의 이름
|
||||
*/
|
||||
export function switchTab(tabName) {
|
||||
DOM.tabButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabName));
|
||||
DOM.tabContents.forEach(content => content.classList.toggle('active', content.id === `${tabName}Tab`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자로부터 현재 필터 값을 가져옵니다.
|
||||
* @returns {{project: string, worker: string, task: string}}
|
||||
*/
|
||||
export function getCurrentFilters() {
|
||||
return {
|
||||
project: DOM.projectFilter.value,
|
||||
worker: DOM.workerFilter.value,
|
||||
task: DOM.taskFilter.value,
|
||||
};
|
||||
}
|
||||
@@ -1,599 +1,128 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
// /js/project-analysis.js
|
||||
import { getMasterData, getWorkReports } from './project-analysis-api.js';
|
||||
import { processRawData, applyFilters, getAnalysis } from './project-analysis-data.js';
|
||||
import {
|
||||
setDefaultDates,
|
||||
setUIState,
|
||||
updateFilterOptions,
|
||||
renderSummary,
|
||||
renderAnalysisTables,
|
||||
renderDetailTable,
|
||||
switchTab,
|
||||
getCurrentFilters,
|
||||
} from './project-analysis-ui.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 state = {
|
||||
masterData: null,
|
||||
processedData: [],
|
||||
filteredData: [],
|
||||
};
|
||||
|
||||
const analysisCard = document.getElementById('analysisCard');
|
||||
const summaryCards = document.getElementById('summaryCards');
|
||||
// DOM 요소 참조 (이벤트 리스너 설정용)
|
||||
const DOM = {
|
||||
startDate: document.getElementById('startDate'),
|
||||
endDate: document.getElementById('endDate'),
|
||||
analyzeBtn: document.getElementById('analyzeBtn'),
|
||||
quickMonthBtn: document.getElementById('quickMonth'),
|
||||
quickLastMonthBtn: document.getElementById('quickLastMonth'),
|
||||
applyFilterBtn: document.getElementById('applyFilter'),
|
||||
tabButtons: document.querySelectorAll('.tab-button'),
|
||||
};
|
||||
|
||||
// 필터 요소들
|
||||
const projectFilter = document.getElementById('projectFilter');
|
||||
const workerFilter = document.getElementById('workerFilter');
|
||||
const taskFilter = document.getElementById('taskFilter');
|
||||
const applyFilterBtn = document.getElementById('applyFilter');
|
||||
/**
|
||||
* 분석 실행 버튼 클릭 이벤트 핸들러
|
||||
*/
|
||||
async function handleAnalysis() {
|
||||
const startDate = DOM.startDate.value;
|
||||
const endDate = DOM.endDate.value;
|
||||
|
||||
// 탭 요소들
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
const tabContents = document.querySelectorAll('.analysis-content');
|
||||
if (!startDate || !endDate || startDate > endDate) {
|
||||
alert('올바른 분석 기간을 설정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블 바디들
|
||||
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() {
|
||||
setUIState('loading');
|
||||
try {
|
||||
console.log('API 주소:', API);
|
||||
console.log('인증 헤더:', getAuthHeaders());
|
||||
const rawReports = await getWorkReports(startDate, endDate);
|
||||
state.processedData = processRawData(rawReports, state.masterData);
|
||||
|
||||
const res = await fetch(`${API}/workers`, { headers: getAuthHeaders() });
|
||||
console.log('작업자 API 응답 상태:', res.status);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP 오류: ${res.status} ${res.statusText}`);
|
||||
if (state.processedData.length === 0) {
|
||||
setUIState('no-data');
|
||||
updateFilterOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
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}`);
|
||||
updateFilterOptions(state.processedData);
|
||||
handleFilterChange(); // 필터 적용 및 렌더링
|
||||
setUIState('data');
|
||||
|
||||
} catch (error) {
|
||||
console.error('분석 처리 중 오류:', error);
|
||||
setUIState('error');
|
||||
alert(error.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 = [];
|
||||
}
|
||||
/**
|
||||
* 필터 적용 버튼 클릭 또는 분석 후 자동 실행되는 핸들러
|
||||
*/
|
||||
function handleFilterChange() {
|
||||
const filters = getCurrentFilters();
|
||||
state.filteredData = applyFilters(state.processedData, filters);
|
||||
|
||||
const analysisResult = getAnalysis(state.filteredData);
|
||||
|
||||
renderSummary(analysisResult.summary);
|
||||
renderAnalysisTables(analysisResult);
|
||||
renderDetailTable(state.filteredData);
|
||||
}
|
||||
|
||||
// 작업 데이터 로딩
|
||||
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 = [];
|
||||
}
|
||||
/**
|
||||
* 빠른 날짜 설정 버튼 핸들러
|
||||
* @param {'this' | 'last'} monthType - 이번 달 또는 지난 달
|
||||
*/
|
||||
function handleQuickDate(monthType) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const firstDay = monthType === 'this' ? new Date(year, month, 1) : new Date(year, month - 1, 1);
|
||||
const lastDay = monthType === 'this' ? new Date(year, month + 1, 0) : new Date(year, month, 0);
|
||||
|
||||
DOM.startDate.value = firstDay.toISOString().split('T')[0];
|
||||
DOM.endDate.value = lastDay.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
analyzeBtn.addEventListener('click', analyzeData);
|
||||
quickMonthBtn.addEventListener('click', setThisMonth);
|
||||
quickLastMonthBtn.addEventListener('click', setLastMonth);
|
||||
applyFilterBtn.addEventListener('click', applyFilters);
|
||||
|
||||
// 탭 전환
|
||||
tabButtons.forEach(btn => {
|
||||
DOM.analyzeBtn.addEventListener('click', handleAnalysis);
|
||||
DOM.applyFilterBtn.addEventListener('click', handleFilterChange);
|
||||
DOM.quickMonthBtn.addEventListener('click', () => handleQuickDate('this'));
|
||||
DOM.quickLastMonthBtn.addEventListener('click', () => handleQuickDate('last'));
|
||||
|
||||
DOM.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();
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
async function initialize() {
|
||||
setDefaultDates();
|
||||
setupEventListeners();
|
||||
|
||||
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}`);
|
||||
state.masterData = await getMasterData();
|
||||
// 페이지 로드 시 바로 분석 실행
|
||||
await handleAnalysis();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
setUIState('error');
|
||||
}
|
||||
}
|
||||
|
||||
// 실제 투입시간 계산 함수
|
||||
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();
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
Reference in New Issue
Block a user