refactor: 작업보고서 조회 페이지 삭제 및 출근체크 버그 수정

- report-view.html 및 관련 파일 삭제 (리소스 최적화)
  - work-report-calendar.js/css
  - modules/calendar/* (CalendarState, CalendarAPI, CalendarView)
  - report-viewer-*.js (미사용)
  - daily-report-viewer.js/css (미사용)
- 사이드바에서 작업보고서 조회 링크 제거
- 출근체크 페이지: 날짜 변경 시 자동 새로고침 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-05 09:44:19 +09:00
parent 170adcc149
commit 665a5b1b7d
13 changed files with 3 additions and 4732 deletions

View File

@@ -1,81 +0,0 @@
// /js/daily-report-viewer.js
import { fetchReportData } from './report-viewer-api.js';
import { renderReport, processReportData, showLoading, showError } from './report-viewer-ui.js';
import { exportToExcel, printReport } from './report-viewer-export.js';
import { getUser } from './auth.js';
// 전역 상태: 현재 화면에 표시된 데이터
let currentProcessedData = null;
/**
* 날짜를 기준으로 보고서를 검색하고 화면에 렌더링합니다.
*/
async function searchReports() {
const dateInput = document.getElementById('reportDate');
const selectedDate = dateInput.value;
if (!selectedDate) {
showError('날짜를 선택해주세요.');
return;
}
showLoading(true);
currentProcessedData = null; // 새 검색이 시작되면 이전 데이터 초기화
try {
const rawData = await fetchReportData(selectedDate);
currentProcessedData = processReportData(rawData, selectedDate);
renderReport(currentProcessedData);
} catch (error) {
showError(error.message);
renderReport(null); // 에러 발생 시 데이터 없는 화면 표시
} finally {
showLoading(false);
}
}
/**
* 페이지의 모든 이벤트 리스너를 설정합니다.
*/
function setupEventListeners() {
document.getElementById('searchBtn')?.addEventListener('click', searchReports);
document.getElementById('todayBtn')?.addEventListener('click', () => {
const today = new Date().toISOString().split('T')[0];
document.getElementById('reportDate').value = today;
searchReports();
});
document.getElementById('reportDate')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') searchReports();
});
document.getElementById('exportExcelBtn')?.addEventListener('click', () => {
exportToExcel(currentProcessedData);
});
document.getElementById('printBtn')?.addEventListener('click', printReport);
}
/**
* 페이지가 처음 로드될 때 실행되는 초기화 함수
*/
function initializePage() {
// auth.js를 사용하여 인증 상태 확인
const user = getUser();
if (!user) {
showError('로그인이 필요합니다. 2초 후 로그인 페이지로 이동합니다.');
setTimeout(() => window.location.href = '/index.html', 2000);
return;
}
setupEventListeners();
// 페이지 로드 시 오늘 날짜로 자동 검색
const dateInput = document.getElementById('reportDate');
dateInput.value = new Date().toISOString().split('T')[0];
searchReports();
}
// DOM이 로드되면 페이지 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializePage);

View File

@@ -1,152 +0,0 @@
// web-ui/js/modules/calendar/CalendarAPI.js
/**
* 캘린더와 관련된 모든 API 호출을 관리하는 전역 객체입니다.
*/
(function(window) {
'use strict';
const CalendarAPI = {};
/**
* 활성화된 모든 작업자 목록을 가져옵니다.
* @returns {Promise<Array>} 작업자 객체 배열
*/
CalendarAPI.getWorkers = async function() {
try {
// api-helper.js 에 정의된 전역 apiGet 함수를 사용합니다.
const response = await window.apiGet('/workers');
if (response.success && Array.isArray(response.data)) {
// 활성화된 작업자만 필터링
const activeWorkers = response.data.filter(worker =>
worker.status === 'active' || worker.is_active === 1 || worker.is_active === true
);
return activeWorkers;
}
console.warn('API 응답 형식이 올바르지 않거나 데이터가 없습니다:', response);
return [];
} catch (error) {
console.error('작업자 데이터 로딩 중 API 오류 발생:', error);
// 에러를 다시 던져서 호출부에서 처리할 수 있도록 함
throw new Error('작업자 데이터를 불러오는 데 실패했습니다.');
}
};
/**
* 월별 작업 데이터 로드 (집계 테이블 사용으로 최적화)
* @param {number} year
* @param {number} month (0-indexed)
* @returns {Promise<object>}
*/
CalendarAPI.getMonthlyCalendarData = async function(year, month) {
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
try {
const response = await window.apiGet(`/monthly-status/calendar?year=${year}&month=${month + 1}`);
if (response.success) {
return response.data;
} else {
throw new Error(response.message || '집계 데이터 조회 실패');
}
} catch (error) {
console.error(`${monthKey} 집계 데이터 로딩 오류:`, error);
console.log(`📋 폴백: ${monthKey} 기존 방식 로딩 시작...`);
return await _getMonthlyWorkDataFallback(year, month);
}
};
/**
* 일일 상세 데이터 조회 (모달용)
* @param {string} dateStr (YYYY-MM-DD)
* @returns {Promise<object>}
*/
CalendarAPI.getDailyDetails = async function(dateStr) {
try {
const response = await window.apiGet(`/monthly-status/daily-details?date=${dateStr}`);
if (response.success) {
return response.data;
}
// Fallback to old API if new one fails
const fallbackResponse = await window.apiGet(`/daily-work-reports?date=${dateStr}&view_all=true`);
return {
workers: fallbackResponse.data, // Assuming structure is different
summary: {} // No summary in fallback
};
} catch (error) {
console.error('일일 작업 데이터 로딩 오류:', error);
throw new Error('해당 날짜의 작업 데이터를 불러오는 데 실패했습니다.');
}
};
/**
* 특정 작업자의 하루치 작업을 모두 삭제합니다.
* @param {number} workerId
* @param {string} date (YYYY-MM-DD)
* @returns {Promise<object>}
*/
CalendarAPI.deleteWorkerDayWork = async function(workerId, date) {
return await window.apiDelete(`/daily-work-reports/date/${date}/worker/${workerId}`);
};
/**
* 폴백: 순차적 로딩 (지연 시간 포함) - Private helper
* @param {number} year
* @param {number} month (0-indexed)
* @returns {Promise<object>}
*/
async function _getMonthlyWorkDataFallback(year, month) {
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
const monthData = {};
try {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const currentDay = new Date(firstDay);
console.log(`📅 폴백: ${monthKey} 순차 로딩 시작 (rate limit 방지)`);
// 순차적으로 요청하되 작은 배치로 나눔 (5개씩)
const BATCH_SIZE = 5;
const DELAY_BETWEEN_BATCHES = 100; // 100ms
let day = 1;
while (currentDay <= lastDay) {
const batch = [];
// 배치 생성
for (let i = 0; i < BATCH_SIZE && currentDay <= lastDay; i++) {
const dateStr = currentDay.toISOString().split('T')[0];
batch.push({
date: dateStr,
promise: window.apiGet(`/daily-work-reports?date=${dateStr}&view_all=true`)
});
currentDay.setDate(currentDay.getDate() + 1);
}
// 배치 실행
const results = await Promise.all(batch.map(b => b.promise));
// 결과 저장
batch.forEach((item, index) => {
const result = results[index];
monthData[item.date] = result.success && Array.isArray(result.data) ? result.data : [];
});
// 다음 배치 전 잠시 대기 (rate limit 방지)
if (currentDay <= lastDay) {
await new Promise(resolve => setTimeout(resolve, DELAY_BETWEEN_BATCHES));
}
}
console.log(`✅ 폴백: ${monthKey} 순차 로딩 완료`);
return monthData;
} catch (error) {
console.error(`${monthKey} 순차 로딩 오류:`, error);
throw new Error('작업 데이터를 불러오는 데 실패했습니다.');
}
}
// 전역 스코프에 CalendarAPI 객체 할당
window.CalendarAPI = CalendarAPI;
})(window);

View File

@@ -1,34 +0,0 @@
// web-ui/js/modules/calendar/CalendarState.js
/**
* 캘린더 페이지의 모든 상태를 관리하는 전역 객체입니다.
*/
(function(window) {
'use strict';
const CalendarState = {
// 캘린더 상태
currentDate: new Date(),
monthlyData: {}, // 월별 데이터 캐시
allWorkers: [], // 전체 작업자 목록 캐시
// 모달 상태
currentModalDate: null,
currentEditingWork: null,
existingWorks: [],
// 상태 초기화
reset: function() {
this.currentDate = new Date();
this.monthlyData = {};
// allWorkers는 유지
this.currentModalDate = null;
this.currentEditingWork = null;
this.existingWorks = [];
}
};
// 전역 스코프에 CalendarState 객체 할당
window.CalendarState = CalendarState;
})(window);

View File

@@ -1,121 +0,0 @@
// web-ui/js/modules/calendar/CalendarView.js
/**
* 캘린더 UI 렌더링 및 DOM 조작을 담당하는 전역 객체입니다.
*/
(function(window) {
'use strict';
const CalendarView = {
elements: {},
initializeElements: function() {
this.elements.monthYearTitle = document.getElementById('monthYearTitle');
this.elements.calendarDays = document.getElementById('calendarDays');
this.elements.prevMonthBtn = document.getElementById('prevMonthBtn');
this.elements.nextMonthBtn = document.getElementById('nextMonthBtn');
this.elements.todayBtn = document.getElementById('todayBtn');
this.elements.dailyWorkModal = document.getElementById('dailyWorkModal');
this.elements.modalTitle = document.getElementById('modalTitle');
this.elements.modalSummary = document.querySelector('.daily-summary');
this.elements.modalTotalWorkers = document.getElementById('modalTotalWorkers');
this.elements.modalTotalHours = document.getElementById('modalTotalHours');
this.elements.modalTotalTasks = document.getElementById('modalTotalTasks');
this.elements.modalErrorCount = document.getElementById('modalErrorCount');
this.elements.modalWorkersList = document.getElementById('modalWorkersList');
this.elements.modalNoData = document.getElementById('modalNoData');
this.elements.statusFilter = document.getElementById('statusFilter');
this.elements.loadingSpinner = document.getElementById('loadingSpinner');
},
showLoading: function(show) {
if (this.elements.loadingSpinner) {
this.elements.loadingSpinner.style.display = show ? 'flex' : 'none';
}
},
showToast: function(message, type = 'info') {
const existingToast = document.querySelector('.toast-message');
if (existingToast) existingToast.remove();
const toast = document.createElement('div');
toast.className = `toast-message toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
},
renderCalendar: async function() {
const year = CalendarState.currentDate.getFullYear();
const month = CalendarState.currentDate.getMonth();
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
this.elements.monthYearTitle.textContent = `${year}${monthNames[month]}`;
this.showLoading(true);
try {
const monthData = await CalendarAPI.getMonthlyCalendarData(year, month);
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
let calendarHTML = '';
let currentDay = new Date(startDate);
for (let i = 0; i < 42; i++) {
const dateStr = `${currentDay.getFullYear()}-${String(currentDay.getMonth() + 1).padStart(2, '0')}-${String(currentDay.getDate()).padStart(2, '0')}`;
const dayWorkData = monthData[dateStr] || { hasData: false, hasIssues: false, hasErrors: false, workerCount: 0 };
const dayStatus = this.analyzeDayStatus(dayWorkData);
let dayClasses = ['calendar-day'];
if (currentDay.getMonth() !== month) dayClasses.push('other-month');
if (dateStr === todayStr) dayClasses.push('today');
if (currentDay.getDay() === 0) dayClasses.push('sunday');
if (currentDay.getDay() === 6) dayClasses.push('saturday');
const hasAnyProblem = dayStatus.hasOvertimeWarning || dayStatus.hasIncomplete || dayStatus.hasIssues;
if (dayStatus.hasData && !hasAnyProblem) dayClasses.push('has-normal');
let statusIcons = '';
if (hasAnyProblem) {
if (dayStatus.hasOvertimeWarning) statusIcons += '<div class="legend-icon purple">●</div>';
if (dayStatus.hasIncomplete) statusIcons += '<div class="legend-icon red">●</div>';
if (dayStatus.hasIssues) statusIcons += '<div class="legend-icon orange">●</div>';
}
calendarHTML += `<div class="${dayClasses.join(' ')}" onclick="openDailyWorkModal('${dateStr}')"><div class="day-number">${currentDay.getDate()}</div>${statusIcons}</div>`;
currentDay.setDate(currentDay.getDate() + 1);
}
this.elements.calendarDays.innerHTML = calendarHTML;
} catch (error) {
console.error('캘린더 렌더링 오류:', error);
this.showToast('캘린더를 불러오는데 실패했습니다.', 'error');
} finally {
this.showLoading(false);
}
},
analyzeDayStatus: function(dayData) {
if (dayData && typeof dayData === 'object' && 'totalWorkers' in dayData) {
const totalRegisteredWorkers = CalendarState.allWorkers ? CalendarState.allWorkers.length : 0;
const actualIncompleteWorkers = Math.max(0, totalRegisteredWorkers - dayData.workingWorkers);
return {
hasData: dayData.totalWorkers > 0,
hasIssues: dayData.partialWorkers > 0,
hasIncomplete: actualIncompleteWorkers > 0 || dayData.incompleteWorkers > 0,
hasOvertimeWarning: dayData.hasOvertimeWarning || dayData.overtimeWarningWorkers > 0,
workerCount: dayData.totalWorkers || 0
};
}
return { hasData: false, hasIssues: false, hasErrors: false, workerCount: 0 };
}
};
window.CalendarView = CalendarView;
})(window);

View File

@@ -1,91 +0,0 @@
// /js/report-viewer-api.js
import { apiGet } from './api-helper.js';
import { getUser } from './auth.js';
/**
* 보고서 조회를 위한 마스터 데이터를 로드합니다. (작업 유형, 상태 등)
* 실패 시 기본값을 반환할 수 있도록 개별적으로 처리합니다.
* @returns {Promise<object>} - 각 마스터 데이터 배열을 포함하는 객체
*/
export async function loadMasterData() {
const masterData = {
workTypes: [],
workStatusTypes: [],
errorTypes: []
};
try {
// Promise.allSettled를 사용해 일부 API가 실패해도 전체가 중단되지 않도록 함
const results = await Promise.allSettled([
apiGet('/daily-work-reports/work-types'),
apiGet('/daily-work-reports/work-status-types'),
apiGet('/daily-work-reports/error-types')
]);
if (results[0].status === 'fulfilled') masterData.workTypes = results[0].value;
if (results[1].status === 'fulfilled') masterData.workStatusTypes = results[1].value;
if (results[2].status === 'fulfilled') masterData.errorTypes = results[2].value;
return masterData;
} catch (error) {
console.error('마스터 데이터 로딩 중 심각한 오류 발생:', error);
// 최소한의 기본값이라도 반환
return masterData;
}
}
/**
* 사용자의 권한을 확인하여 적절한 API 엔드포인트와 파라미터를 결정합니다.
* @param {string} selectedDate - 조회할 날짜
* @returns {string} - 호출할 API URL
*/
function getReportApiUrl(selectedDate) {
const user = getUser();
// 관리자(admin, system)는 모든 데이터를 조회
if (user && (user.role === 'admin' || user.role === 'system')) {
// 백엔드에서 GET /daily-work-reports?date=YYYY-MM-DD 요청 시
// 권한을 확인하고 모든 데이터를 내려준다고 가정
return `/daily-work-reports?date=${selectedDate}`;
}
// 그 외 사용자(leader, user)는 본인이 생성한 데이터만 조회
// 백엔드에서 동일한 엔드포인트로 요청 시, 권한을 확인하고
// 본인 데이터만 필터링해서 내려준다고 가정
// (만약 엔드포인트가 다르다면 이 부분을 수정해야 함)
return `/daily-work-reports?date=${selectedDate}`;
}
/**
* 특정 날짜의 작업 보고서 데이터를 서버에서 가져옵니다.
* @param {string} selectedDate - 조회할 날짜 (YYYY-MM-DD)
* @returns {Promise<Array>} - 작업 보고서 데이터 배열
*/
export async function fetchReportData(selectedDate) {
if (!selectedDate) {
throw new Error('조회할 날짜가 선택되지 않았습니다.');
}
const apiUrl = getReportApiUrl(selectedDate);
try {
const rawData = await apiGet(apiUrl);
// 서버 응답이 { success: true, data: [...] } 형태일 경우와 [...] 형태일 경우 모두 처리
if (rawData && rawData.success && Array.isArray(rawData.data)) {
return rawData.data;
}
if (Array.isArray(rawData)) {
return rawData;
}
// 예상치 못한 형식의 응답
console.warn('예상치 못한 형식의 API 응답:', rawData);
return [];
} catch (error) {
console.error(`${selectedDate}의 작업 보고서 조회 실패:`, error);
throw new Error('서버에서 데이터를 가져오는 데 실패했습니다.');
}
}

View File

@@ -1,72 +0,0 @@
// /js/report-viewer-export.js
/**
* 주어진 데이터를 CSV 형식의 문자열로 변환합니다.
* @param {object} reportData - 요약 및 작업자별 데이터
* @returns {string} - CSV 형식의 문자열
*/
function convertToCsv(reportData) {
let csvContent = "\uFEFF"; // UTF-8 BOM
csvContent += "작업자명,프로젝트명,작업유형,작업상태,에러유형,작업시간,입력자\n";
reportData.workers.forEach(worker => {
worker.entries.forEach(entry => {
const row = [
worker.worker_name,
entry.project_name,
entry.work_type_name,
entry.work_status_name,
entry.error_type_name,
entry.work_hours,
entry.created_by_name
].map(field => `"${String(field || '').replace(/"/g, '""')}"`).join(',');
csvContent += row + "\n";
});
});
return csvContent;
}
/**
* 가공된 보고서 데이터를 CSV 파일로 다운로드합니다.
* @param {object|null} reportData - UI에 표시된 가공된 데이터
*/
export function exportToExcel(reportData) {
if (!reportData || !reportData.workers || reportData.workers.length === 0) {
alert('내보낼 데이터가 없습니다.');
return;
}
try {
const csv = convertToCsv(reportData);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const fileName = `작업보고서_${reportData.summary.date}.csv`;
link.setAttribute('href', url);
link.setAttribute('download', fileName);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Excel 내보내기 실패:', error);
alert('Excel 파일을 생성하는 중 오류가 발생했습니다.');
}
}
/**
* 현재 페이지의 인쇄 기능을 호출합니다.
*/
export function printReport() {
try {
window.print();
} catch (error) {
console.error('인쇄 실패:', error);
alert('인쇄 중 오류가 발생했습니다.');
}
}

View File

@@ -1,144 +0,0 @@
// /js/report-viewer-ui.js
/**
* 데이터를 가공하여 UI에 표시하기 좋은 요약 형태로 변환합니다.
* @param {Array} rawData - 서버에서 받은 원시 데이터 배열
* @param {string} selectedDate - 선택된 날짜
* @returns {object} - 요약 정보와 작업자별로 그룹화된 데이터를 포함하는 객체
*/
export function processReportData(rawData, selectedDate) {
if (!Array.isArray(rawData) || rawData.length === 0) {
return null;
}
const workerGroups = {};
let totalHours = 0;
let errorCount = 0;
rawData.forEach(item => {
const workerName = item.worker_name || '미지정';
const workHours = parseFloat(item.work_hours || 0);
totalHours += workHours;
if (item.work_status_id === 2) errorCount++; // '에러' 상태 ID가 2라고 가정
if (!workerGroups[workerName]) {
workerGroups[workerName] = {
worker_name: workerName,
total_hours: 0,
entries: []
};
}
workerGroups[workerName].total_hours += workHours;
workerGroups[workerName].entries.push(item);
});
return {
summary: {
date: selectedDate,
total_workers: Object.keys(workerGroups).length,
total_hours: totalHours,
total_entries: rawData.length,
error_count: errorCount
},
workers: Object.values(workerGroups)
};
}
function displaySummary(summary) {
const elements = {
totalWorkers: summary.total_workers,
totalHours: `${summary.total_hours}시간`,
totalEntries: `${summary.total_entries}`,
errorCount: `${summary.error_count}`
};
Object.entries(elements).forEach(([id, value]) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
});
document.getElementById('reportSummary').style.display = 'block';
}
function createWorkEntryElement(entry) {
const entryDiv = document.createElement('div');
entryDiv.className = `work-entry ${entry.work_status_id === 2 ? 'error-entry' : ''}`;
entryDiv.innerHTML = `
<div class="entry-header">
<div class="project-name">${entry.project_name || '프로젝트 미지정'}</div>
<div class="work-hours">${entry.work_hours || 0}시간</div>
</div>
<div class="entry-details">
<div class="entry-detail">
<span class="detail-label">작업 유형:</span>
<span class="detail-value">${entry.work_type_name || '-'}</span>
</div>
${entry.work_status_id === 2 ? `
<div class="entry-detail">
<span class="detail-label">에러 유형:</span>
<span class="detail-value error-type">${entry.error_type_name || '에러'}</span>
</div>` : ''}
</div>
`;
return entryDiv;
}
function displayWorkersDetails(workers) {
const workersListEl = document.getElementById('workersList');
workersListEl.innerHTML = '';
workers.forEach(worker => {
const workerCard = document.createElement('div');
workerCard.className = 'worker-card';
workerCard.innerHTML = `
<div class="worker-header">
<div class="worker-name">👤 ${worker.worker_name}</div>
<div class="worker-total-hours">총 ${worker.total_hours}시간</div>
</div>
`;
const entriesContainer = document.createElement('div');
entriesContainer.className = 'work-entries';
worker.entries.forEach(entry => entriesContainer.appendChild(createWorkEntryElement(entry)));
workerCard.appendChild(entriesContainer);
workersListEl.appendChild(workerCard);
});
document.getElementById('workersReport').style.display = 'block';
}
const hideElement = (id) => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
};
/**
* 가공된 데이터를 받아 화면 전체를 렌더링합니다.
* @param {object|null} processedData - 가공된 데이터 또는 데이터가 없을 경우 null
*/
export function renderReport(processedData) {
hideElement('loadingSpinner');
hideElement('errorMessage');
hideElement('noDataMessage');
hideElement('reportSummary');
hideElement('workersReport');
hideElement('exportSection');
if (!processedData) {
document.getElementById('noDataMessage').style.display = 'block';
return;
}
displaySummary(processedData.summary);
displayWorkersDetails(processedData.workers);
document.getElementById('exportSection').style.display = 'block';
}
export function showLoading(isLoading) {
document.getElementById('loadingSpinner').style.display = isLoading ? 'flex' : 'none';
if(isLoading) {
hideElement('errorMessage');
hideElement('noDataMessage');
}
}
export function showError(message) {
const errorEl = document.getElementById('errorMessage');
errorEl.querySelector('.error-text').textContent = message;
errorEl.style.display = 'block';
hideElement('loadingSpinner');
}

File diff suppressed because it is too large Load Diff