diff --git a/web-ui/js/project-analysis-api.js b/web-ui/js/project-analysis-api.js new file mode 100644 index 0000000..239f8d5 --- /dev/null +++ b/web-ui/js/project-analysis-api.js @@ -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} + */ +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}`); + } +} \ No newline at end of file diff --git a/web-ui/js/project-analysis-data.js b/web-ui/js/project-analysis-data.js new file mode 100644 index 0000000..7df5838 --- /dev/null +++ b/web-ui/js/project-analysis-data.js @@ -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 }; +} \ No newline at end of file diff --git a/web-ui/js/project-analysis-ui.js b/web-ui/js/project-analysis-ui.js new file mode 100644 index 0000000..f03efe1 --- /dev/null +++ b/web-ui/js/project-analysis-ui.js @@ -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 = `${message}`; + const detailHtml = `${message}`; + 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 = ''; + [...new Set(items)].sort().forEach(item => { + html += ``; + }); + 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 = ` +

총 투입 시간

${summary.totalHours.toFixed(1)}h
+

참여 프로젝트

${summary.totalProjects}개
+

참여 인원

${summary.totalWorkers}명
+

작업 분류

${summary.totalTasks}개
+ `; +} + +/** + * 집계된 데이터를 받아 테이블을 렌더링하는 범용 함수 + * @param {HTMLElement} tableBodyEl - 렌더링할 테이블의 tbody 요소 + * @param {Array} data - 집계된 데이터 배열 + * @param {function} rowRenderer - 각 행을 렌더링하는 함수 + */ +function renderTable(tableBodyEl, data, rowRenderer) { + if (data.length === 0) { + tableBodyEl.innerHTML = '데이터가 없습니다'; + 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) => ` + ${i + 1}${p.name}${p.hours.toFixed(1)}h + ${(p.hours / total * 100).toFixed(1)}%${p.participants.size}명`); + + renderTable(DOM.workerTableBody, analysis.byWorker, (w, i, total) => ` + ${i + 1}${w.name}${w.hours.toFixed(1)}h + ${(w.hours / total * 100).toFixed(1)}%${w.participants.size}개`); + + renderTable(DOM.taskTableBody, analysis.byTask, (t, i, total) => ` + ${i + 1}${t.name}${t.hours.toFixed(1)}h + ${(t.hours / total * 100).toFixed(1)}%${t.participants.size}명`); +} + +/** + * 상세 내역 테이블을 렌더링합니다. + * @param {Array} detailData - 필터링된 상세 데이터 + */ +export function renderDetailTable(detailData) { + if (detailData.length === 0) { + DOM.detailTableBody.innerHTML = '데이터가 없습니다'; + return; + } + const sorted = [...detailData].sort((a, b) => new Date(b.date) - new Date(a.date)); + DOM.detailTableBody.innerHTML = sorted.map((item, index) => ` + ${index + 1}${formatDate(new Date(item.date))} + ${item.project_name} + ${item.worker_name}${item.task_category} + ${item.work_details || '정상근무'} + ${(item.work_hours || 0).toFixed(1)}h + ${(item.memo || '-').substring(0, 20)}` + ).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, + }; +} \ No newline at end of file diff --git a/web-ui/js/project-analysis.js b/web-ui/js/project-analysis.js index 2fbd150..66c0b4a 100644 --- a/web-ui/js/project-analysis.js +++ b/web-ui/js/project-analysis.js @@ -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 = ''; - projects.forEach(project => { - projectFilter.insertAdjacentHTML('beforeend', ``); - }); - - // 작업자 필터 - const workerNames = [...new Set(filteredData.map(item => item.worker_name))].sort(); - workerFilter.innerHTML = ''; - workerNames.forEach(name => { - workerFilter.insertAdjacentHTML('beforeend', ``); - }); - - // 작업 분류 필터 - const tasks = [...new Set(filteredData.map(item => item.task_category))].sort(); - taskFilter.innerHTML = ''; - tasks.forEach(task => { - taskFilter.insertAdjacentHTML('beforeend', ``); - }); -} - -// 필터 적용 -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 = ` -
-

총 투입 시간

-
${totalHours.toFixed(1)}h
-
-
-

참여 프로젝트

-
${totalProjects}개
-
-
-

참여 인원

-
${totalWorkers}명
-
-
-

작업 분류

-
${totalTasks}개
-
- `; -} - -// 모든 테이블 렌더링 -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 = '데이터가 없습니다'; - } else { - sortedProjects.forEach((project, index) => { - const ratio = totalHours > 0 ? (project.hours / totalHours * 100).toFixed(1) : 0; - html += ` - - ${index + 1} - ${project.name} - ${project.hours.toFixed(1)}h - ${ratio}% - ${project.workers.size}명 - - `; - }); - } - - 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 = '데이터가 없습니다'; - } else { - sortedWorkers.forEach((worker, index) => { - const ratio = totalHours > 0 ? (worker.hours / totalHours * 100).toFixed(1) : 0; - html += ` - - ${index + 1} - ${worker.name} - ${worker.hours.toFixed(1)}h - ${ratio}% - ${worker.projects.size}개 - - `; - }); - } - - 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 = '데이터가 없습니다'; - } else { - sortedTasks.forEach((task, index) => { - const ratio = totalHours > 0 ? (task.hours / totalHours * 100).toFixed(1) : 0; - html += ` - - ${index + 1} - ${task.name} - ${task.hours.toFixed(1)}h - ${ratio}% - ${task.workers.size}명 - - `; - }); - } - - 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 = '데이터가 없습니다'; - } 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 += ` - - ${index + 1} - ${date} - ${item.project_name} - ${item.worker_name} - ${item.task_category} - ${workDetailsDisplay} - ${parseFloat(item.work_hours || 0).toFixed(1)}h - ${memo.length > 20 ? memo.substring(0, 20) + '...' : memo} - - `; - }); - } - - 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 = '📊 데이터 분석 중...'; - projectTableBody.innerHTML = loadingHtml; - workerTableBody.innerHTML = loadingHtml; - taskTableBody.innerHTML = loadingHtml; - detailTableBody.innerHTML = '📊 데이터 분석 중...'; -} - // 초기화 실행 -initialize(); \ No newline at end of file +document.addEventListener('DOMContentLoaded', initialize); \ No newline at end of file