perf(frontend): 프로젝트 분석 페이지 성능 최적화

- 백엔드 집계 API(/api/analysis)를 연동하여 프론트엔드 데이터 처리 로직 제거
- 불필요해진 project-analysis-data.js 파일 삭제
- 페이지 로딩 및 데이터 분석 속도를 획기적으로 개선
This commit is contained in:
2025-07-28 15:21:19 +09:00
parent 7c6940307e
commit a1b7d3c5df
4 changed files with 49 additions and 194 deletions

View File

@@ -3,6 +3,7 @@ import { apiGet } from './api-helper.js';
/** /**
* 분석 페이지에 필요한 모든 초기 데이터(마스터 데이터)를 병렬로 가져옵니다. * 분석 페이지에 필요한 모든 초기 데이터(마스터 데이터)를 병렬로 가져옵니다.
* 이 데이터는 필터 옵션을 채우는 데 사용됩니다.
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>} * @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
*/ */
export async function getMasterData() { export async function getMasterData() {
@@ -15,30 +16,22 @@ export async function getMasterData() {
return { workers, projects, tasks }; return { workers, projects, tasks };
} catch (error) { } catch (error) {
console.error('마스터 데이터 로딩 실패:', error); console.error('마스터 데이터 로딩 실패:', error);
// 하나라도 실패하면 페이지 기능에 문제가 생길 수 있으므로 에러를 던집니다. throw new Error('필터링에 필요한 데이터를 불러오는 데 실패했습니다.');
throw new Error('페이지 초기화에 필요한 데이터를 불러오는 데 실패했습니다.');
} }
} }
/** /**
* 지정된 기간의 작업 보고서 데이터를 가져옵니다. * 지정된 기간의 모든 분석 데이터를 백엔드에서 직접 가져옵니다.
* 백엔드에 집계 API가 있다면 그쪽을 사용하는 것이 더 효율적입니다.
* (예: /api/analysis/reports?startDate=...&endDate=...)
* 현재는 기존 방식을 유지합니다.
* @param {string} startDate - 시작일 (YYYY-MM-DD) * @param {string} startDate - 시작일 (YYYY-MM-DD)
* @param {string} endDate - 종료일 (YYYY-MM-DD) * @param {string} endDate - 종료일 (YYYY-MM-DD)
* @returns {Promise<Array>} * @returns {Promise<object>} - 요약, 집계, 상세 데이터가 모두 포함된 분석 결과 객체
*/ */
export async function getWorkReports(startDate, endDate) { export async function getAnalysisReport(startDate, endDate) {
try { try {
// API 엔드포인트를 명확하게 수정합니다. const analysisData = await apiGet(`/analysis?startDate=${startDate}&endDate=${endDate}`);
// 기존: /workreports?start=... return analysisData;
// 변경: /daily-work-reports/search?startDate=...&endDate=... (가정)
// 우선 기존 URL 구조를 최대한 따르되, 좀 더 명시적인 경로로 변경 제안
const reports = await apiGet(`/workreports/search?startDate=${startDate}&endDate=${endDate}`);
return reports;
} catch (error) { } catch (error) {
console.error('작업 보고서 데이터 로딩 실패:', error); console.error('분석 보고서 데이터 로딩 실패:', error);
throw new Error(`작업 보고서 데이터를 불러오는 데 실패했습니다: ${error.message}`); throw new Error(`분석 데이터를 불러오는 데 실패했습니다: ${error.message}`);
} }
} }

View File

@@ -1,114 +0,0 @@
// /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 };
}

View File

@@ -68,20 +68,20 @@ export function setUIState(state) {
/** /**
* 필터링된 데이터에서 고유한 값을 추출하여 필터 옵션을 채웁니다. * 마스터 데이터를 기반으로 필터 옵션을 채웁니다.
* @param {Array} data - 가공된 전체 데이터 * @param {{workers: Array, projects: Array, tasks: Array}} masterData - 마스터 데이터
*/ */
export function updateFilterOptions(data) { export function updateFilterOptions(masterData) {
const createOptions = (items) => { const createOptions = (items, key, value) => {
let html = '<option value="">전체</option>'; let html = '<option value="">전체</option>';
[...new Set(items)].sort().forEach(item => { items.forEach(item => {
html += `<option value="${item}">${item}</option>`; html += `<option value="${item[key]}">${item[value]}</option>`;
}); });
return html; return html;
}; };
DOM.projectFilter.innerHTML = createOptions(data.map(d => d.project_name)); DOM.projectFilter.innerHTML = createOptions(masterData.projects, 'project_id', 'project_name');
DOM.workerFilter.innerHTML = createOptions(data.map(d => d.worker_name)); DOM.workerFilter.innerHTML = createOptions(masterData.workers, 'worker_id', 'worker_name');
DOM.taskFilter.innerHTML = createOptions(data.map(d => d.task_category)); DOM.taskFilter.innerHTML = createOptions(masterData.tasks, 'task_id', 'category');
} }
/** /**
@@ -90,10 +90,10 @@ export function updateFilterOptions(data) {
*/ */
export function renderSummary(summary) { export function renderSummary(summary) {
DOM.summaryCards.innerHTML = ` 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.totalHours || 0).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.totalProjects || 0}개</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.totalWorkers || 0}명</div></div>
<div class="summary-card"><h4>작업 분류</h4><div class="value">${summary.totalTasks}개</div></div> <div class="summary-card"><h4>작업 분류</h4><div class="value">${summary.totalTasks || 0}개</div></div>
`; `;
} }
@@ -104,12 +104,11 @@ export function renderSummary(summary) {
* @param {function} rowRenderer - 각 행을 렌더링하는 함수 * @param {function} rowRenderer - 각 행을 렌더링하는 함수
*/ */
function renderTable(tableBodyEl, data, rowRenderer) { function renderTable(tableBodyEl, data, rowRenderer) {
if (data.length === 0) { if (!data || data.length === 0) {
tableBodyEl.innerHTML = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>'; tableBodyEl.innerHTML = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
return; return;
} }
const totalHours = data.reduce((sum, item) => sum + item.hours, 0); tableBodyEl.innerHTML = data.map(rowRenderer).join('');
tableBodyEl.innerHTML = data.map((item, index) => rowRenderer(item, index, totalHours)).join('');
} }
/** /**
@@ -117,17 +116,17 @@ function renderTable(tableBodyEl, data, rowRenderer) {
* @param {object} analysis - 프로젝트/작업자/작업별 집계 데이터 * @param {object} analysis - 프로젝트/작업자/작업별 집계 데이터
*/ */
export function renderAnalysisTables(analysis) { export function renderAnalysisTables(analysis) {
renderTable(DOM.projectTableBody, analysis.byProject, (p, i, total) => ` renderTable(DOM.projectTableBody, analysis.byProject, (p, i) => `
<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> <tr><td>${i + 1}</td><td class="project-col" title="${p.name}">${p.name}</td><td class="hours-col">${p.hours}h</td>
<td>${(p.hours / total * 100).toFixed(1)}%</td><td>${p.participants.size}명</td></tr>`); <td>${p.percentage}%</td><td>${p.participants}명</td></tr>`);
renderTable(DOM.workerTableBody, analysis.byWorker, (w, i, total) => ` renderTable(DOM.workerTableBody, analysis.byWorker, (w, i) => `
<tr><td>${i + 1}</td><td class="worker-col">${w.name}</td><td class="hours-col">${w.hours.toFixed(1)}h</td> <tr><td>${i + 1}</td><td class="worker-col">${w.name}</td><td class="hours-col">${w.hours}h</td>
<td>${(w.hours / total * 100).toFixed(1)}%</td><td>${w.participants.size}개</td></tr>`); <td>${w.percentage}%</td><td>${w.participants}개</td></tr>`);
renderTable(DOM.taskTableBody, analysis.byTask, (t, i, total) => ` renderTable(DOM.taskTableBody, analysis.byTask, (t, i) => `
<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> <tr><td>${i + 1}</td><td class="task-col" title="${t.name}">${t.name}</td><td class="hours-col">${t.hours}h</td>
<td>${(t.hours / total * 100).toFixed(1)}%</td><td>${t.participants.size}명</td></tr>`); <td>${t.percentage}%</td><td>${t.participants}명</td></tr>`);
} }
/** /**
@@ -135,17 +134,16 @@ export function renderAnalysisTables(analysis) {
* @param {Array} detailData - 필터링된 상세 데이터 * @param {Array} detailData - 필터링된 상세 데이터
*/ */
export function renderDetailTable(detailData) { export function renderDetailTable(detailData) {
if (detailData.length === 0) { if (!detailData || detailData.length === 0) {
DOM.detailTableBody.innerHTML = '<tr><td colspan="8" class="no-data">데이터가 없습니다</td></tr>'; DOM.detailTableBody.innerHTML = '<tr><td colspan="8" class="no-data">데이터가 없습니다</td></tr>';
return; return;
} }
const sorted = [...detailData].sort((a, b) => new Date(b.date) - new Date(a.date)); DOM.detailTableBody.innerHTML = detailData.map((item, index) => `
DOM.detailTableBody.innerHTML = sorted.map((item, index) => `
<tr><td>${index + 1}</td><td>${formatDate(new Date(item.date))}</td> <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="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 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>${item.work_details || '정상근무'}</td>
<td class="hours-col">${(item.work_hours || 0).toFixed(1)}h</td> <td class="hours-col">${item.work_hours}h</td>
<td title="${item.memo || '-'}">${(item.memo || '-').substring(0, 20)}</td></tr>` <td title="${item.memo || '-'}">${(item.memo || '-').substring(0, 20)}</td></tr>`
).join(''); ).join('');
} }

View File

@@ -1,6 +1,5 @@
// /js/project-analysis.js // /js/project-analysis.js
import { getMasterData, getWorkReports } from './project-analysis-api.js'; import { getMasterData, getAnalysisReport } from './project-analysis-api.js';
import { processRawData, applyFilters, getAnalysis } from './project-analysis-data.js';
import { import {
setDefaultDates, setDefaultDates,
setUIState, setUIState,
@@ -9,16 +8,8 @@ import {
renderAnalysisTables, renderAnalysisTables,
renderDetailTable, renderDetailTable,
switchTab, switchTab,
getCurrentFilters,
} from './project-analysis-ui.js'; } from './project-analysis-ui.js';
// 애플리케이션 상태 (전역 변수 최소화)
const state = {
masterData: null,
processedData: [],
filteredData: [],
};
// DOM 요소 참조 (이벤트 리스너 설정용) // DOM 요소 참조 (이벤트 리스너 설정용)
const DOM = { const DOM = {
startDate: document.getElementById('startDate'), startDate: document.getElementById('startDate'),
@@ -26,7 +17,8 @@ const DOM = {
analyzeBtn: document.getElementById('analyzeBtn'), analyzeBtn: document.getElementById('analyzeBtn'),
quickMonthBtn: document.getElementById('quickMonth'), quickMonthBtn: document.getElementById('quickMonth'),
quickLastMonthBtn: document.getElementById('quickLastMonth'), quickLastMonthBtn: document.getElementById('quickLastMonth'),
applyFilterBtn: document.getElementById('applyFilter'), // 필터 버튼은 현재 아무 기능도 하지 않으므로 주석 처리 또는 제거 가능
// applyFilterBtn: document.getElementById('applyFilter'),
tabButtons: document.querySelectorAll('.tab-button'), tabButtons: document.querySelectorAll('.tab-button'),
}; };
@@ -44,17 +36,16 @@ async function handleAnalysis() {
setUIState('loading'); setUIState('loading');
try { try {
const rawReports = await getWorkReports(startDate, endDate); const analysisResult = await getAnalysisReport(startDate, endDate);
state.processedData = processRawData(rawReports, state.masterData);
if (state.processedData.length === 0) { if (!analysisResult.summary.totalHours) {
setUIState('no-data'); setUIState('no-data');
updateFilterOptions([]);
return; return;
} }
updateFilterOptions(state.processedData); renderSummary(analysisResult.summary);
handleFilterChange(); // 필터 적용 및 렌더링 renderAnalysisTables(analysisResult);
renderDetailTable(analysisResult.details);
setUIState('data'); setUIState('data');
} catch (error) { } catch (error) {
@@ -64,23 +55,8 @@ async function handleAnalysis() {
} }
} }
/**
* 필터 적용 버튼 클릭 또는 분석 후 자동 실행되는 핸들러
*/
function handleFilterChange() {
const filters = getCurrentFilters();
state.filteredData = applyFilters(state.processedData, filters);
const analysisResult = getAnalysis(state.filteredData);
renderSummary(analysisResult.summary);
renderAnalysisTables(analysisResult);
renderDetailTable(state.filteredData);
}
/** /**
* 빠른 날짜 설정 버튼 핸들러 * 빠른 날짜 설정 버튼 핸들러
* @param {'this' | 'last'} monthType - 이번 달 또는 지난 달
*/ */
function handleQuickDate(monthType) { function handleQuickDate(monthType) {
const now = new Date(); const now = new Date();
@@ -98,13 +74,15 @@ function handleQuickDate(monthType) {
*/ */
function setupEventListeners() { function setupEventListeners() {
DOM.analyzeBtn.addEventListener('click', handleAnalysis); DOM.analyzeBtn.addEventListener('click', handleAnalysis);
DOM.applyFilterBtn.addEventListener('click', handleFilterChange);
DOM.quickMonthBtn.addEventListener('click', () => handleQuickDate('this')); DOM.quickMonthBtn.addEventListener('click', () => handleQuickDate('this'));
DOM.quickLastMonthBtn.addEventListener('click', () => handleQuickDate('last')); DOM.quickLastMonthBtn.addEventListener('click', () => handleQuickDate('last'));
DOM.tabButtons.forEach(btn => { DOM.tabButtons.forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab)); btn.addEventListener('click', () => switchTab(btn.dataset.tab));
}); });
// 프론트엔드 필터링은 제거되었으므로 관련 이벤트 리스너는 주석 처리합니다.
// DOM.applyFilterBtn.addEventListener('click', ...);
} }
/** /**
@@ -115,8 +93,8 @@ async function initialize() {
setupEventListeners(); setupEventListeners();
try { try {
state.masterData = await getMasterData(); const masterData = await getMasterData();
// 페이지 로드 시 바로 분석 실행 updateFilterOptions(masterData);
await handleAnalysis(); await handleAnalysis();
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);