- 600줄에 달하는 project-analysis.js 파일을 API, Data, UI, Controller 네 개의 모듈로 분리 - 복잡한 데이터 처리 로직을 data 모듈로 위임하고, UI 렌더링 코드를 ui 모듈로 분리하여 관심사 분리 원칙(SoC) 적용 - 전역 상태를 최소화하고 데이터 흐름을 명확하게 개선하여 유지보수성 및 안정성 향상
172 lines
7.1 KiB
JavaScript
172 lines
7.1 KiB
JavaScript
// /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,
|
|
};
|
|
}
|