feat: 작업 분석 시스템 및 관리 기능 대폭 개선

 새로운 기능:
- 작업 분석 페이지 구현 (기간별, 프로젝트별, 작업자별, 오류별)
- 개별 분석 실행 버튼으로 API 부하 최적화
- 연차/휴무 집계 방식 개선 (주말 제외, 작업내용 통합)
- 프로젝트 관리 시스템 (활성화/비활성화)
- 작업자 관리 시스템 (CRUD 기능)
- 코드 관리 시스템 (작업유형, 작업상태, 오류유형)

🎨 UI/UX 개선:
- 기간별 작업 현황을 테이블 형태로 변경
- 작업자별 rowspan 그룹화로 가독성 향상
- 연차/휴무 프로젝트 하단 배치 및 시각적 구분
- 기간 확정 시스템으로 사용자 경험 개선
- 반응형 디자인 적용

🔧 기술적 개선:
- Rate Limiting 제거 (내부 시스템 최적화)
- 주말 연차/휴무 자동 제외 로직
- 작업공수 계산 정확도 향상
- 데이터베이스 마이그레이션 추가
- API 엔드포인트 확장 및 최적화

🐛 버그 수정:
- projectSelect 요소 참조 오류 해결
- 차트 높이 무한 증가 문제 해결
- 날짜 표시 형식 단순화
- 작업보고서 저장 validation 오류 수정
This commit is contained in:
Hyungi Ahn
2025-11-04 16:56:47 +09:00
parent 746e09420b
commit de427c457b
46 changed files with 10912 additions and 530 deletions

View File

@@ -71,6 +71,75 @@ function hideMessage() {
document.getElementById('message-container').innerHTML = '';
}
// 저장 결과 모달 표시
function showSaveResultModal(type, title, message, details = null) {
const modal = document.getElementById('saveResultModal');
const titleElement = document.getElementById('resultModalTitle');
const contentElement = document.getElementById('resultModalContent');
// 아이콘 설정
let icon = '';
switch (type) {
case 'success':
icon = '✅';
break;
case 'error':
icon = '❌';
break;
case 'warning':
icon = '⚠️';
break;
default:
icon = '';
}
// 모달 내용 구성
let content = `
<div class="result-icon ${type}">${icon}</div>
<h3 class="result-title ${type}">${title}</h3>
<p class="result-message">${message}</p>
`;
// 상세 정보가 있으면 추가
if (details && details.length > 0) {
content += `
<div class="result-details">
<h4>상세 정보:</h4>
<ul>
${details.map(detail => `<li>${detail}</li>`).join('')}
</ul>
</div>
`;
}
titleElement.textContent = '저장 결과';
contentElement.innerHTML = content;
modal.style.display = 'flex';
// ESC 키로 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeSaveResultModal();
}
});
// 배경 클릭으로 닫기
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeSaveResultModal();
}
});
}
// 저장 결과 모달 닫기
function closeSaveResultModal() {
const modal = document.getElementById('saveResultModal');
modal.style.display = 'none';
// 이벤트 리스너 제거
document.removeEventListener('keydown', closeSaveResultModal);
}
// 단계 이동
function goToStep(stepNumber) {
for (let i = 1; i <= 3; i++) {
@@ -148,10 +217,10 @@ async function loadWorkers() {
async function loadProjects() {
try {
console.log('Projects API 호출 중... (통합 API 사용)');
const data = await window.apiCall(`${window.API}/projects`);
console.log('Projects API 호출 중... (활성 프로젝트만)');
const data = await window.apiCall(`${window.API}/projects/active/list`);
projects = Array.isArray(data) ? data : (data.data || data.projects || []);
console.log('✅ Projects 로드 성공:', projects.length);
console.log('✅ 활성 프로젝트 로드 성공:', projects.length);
} catch (error) {
console.error('프로젝트 로딩 오류:', error);
throw error;
@@ -251,12 +320,16 @@ function toggleWorkerSelection(workerId, btnElement) {
// 작업 항목 추가
function addWorkEntry() {
console.log('🔧 addWorkEntry 함수 호출됨');
const container = document.getElementById('workEntriesList');
console.log('🔧 컨테이너:', container);
workEntryCounter++;
console.log('🔧 작업 항목 카운터:', workEntryCounter);
const entryDiv = document.createElement('div');
entryDiv.className = 'work-entry';
entryDiv.dataset.id = workEntryCounter;
console.log('🔧 생성된 작업 항목 div:', entryDiv);
entryDiv.innerHTML = `
<div class="work-entry-header">
@@ -337,7 +410,12 @@ function addWorkEntry() {
`;
container.appendChild(entryDiv);
console.log('🔧 작업 항목이 컨테이너에 추가됨');
console.log('🔧 현재 컨테이너 내용:', container.innerHTML.length, '문자');
console.log('🔧 현재 .work-entry 개수:', container.querySelectorAll('.work-entry').length);
setupWorkEntryEvents(entryDiv);
console.log('🔧 이벤트 설정 완료');
}
// 작업 항목 이벤트 설정
@@ -432,43 +510,100 @@ async function saveWorkReport() {
const reportDate = document.getElementById('reportDate').value;
if (!reportDate || selectedWorkers.size === 0) {
showMessage('날짜와 작업자를 선택해주세요.', 'error');
showSaveResultModal(
'error',
'입력 오류',
'날짜와 작업자를 선택해주세요.'
);
return;
}
const entries = document.querySelectorAll('.work-entry');
console.log('🔍 찾은 작업 항목들:', entries);
console.log('🔍 작업 항목 개수:', entries.length);
if (entries.length === 0) {
showMessage('최소 하나의 작업을 추가해주세요.', 'error');
showSaveResultModal(
'error',
'작업 항목 없음',
'최소 하나의 작업을 추가해주세요.'
);
return;
}
const newWorkEntries = [];
console.log('🔍 작업 항목 수집 시작...');
for (const entry of entries) {
const projectId = entry.querySelector('.project-select').value;
const workTypeId = entry.querySelector('.work-type-select').value;
const workStatusId = entry.querySelector('.work-status-select').value;
const errorTypeId = entry.querySelector('.error-type-select').value;
const workHours = entry.querySelector('.time-input').value;
console.log('🔍 작업 항목 처리 중:', entry);
const projectSelect = entry.querySelector('.project-select');
const workTypeSelect = entry.querySelector('.work-type-select');
const workStatusSelect = entry.querySelector('.work-status-select');
const errorTypeSelect = entry.querySelector('.error-type-select');
const timeInput = entry.querySelector('.time-input');
console.log('🔍 선택된 요소들:', {
projectSelect,
workTypeSelect,
workStatusSelect,
errorTypeSelect,
timeInput
});
const projectId = projectSelect?.value;
const workTypeId = workTypeSelect?.value;
const workStatusId = workStatusSelect?.value;
const errorTypeId = errorTypeSelect?.value;
const workHours = timeInput?.value;
console.log('🔍 수집된 값들:', {
projectId,
workTypeId,
workStatusId,
errorTypeId,
workHours
});
if (!projectId || !workTypeId || !workStatusId || !workHours) {
showMessage('모든 작업 항목을 완성해주세요.', 'error');
showSaveResultModal(
'error',
'입력 오류',
'모든 작업 항목을 완성해주세요.'
);
return;
}
if (workStatusId === '2' && !errorTypeId) {
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
showSaveResultModal(
'error',
'입력 오류',
'에러 상태인 경우 에러 유형을 선택해주세요.'
);
return;
}
newWorkEntries.push({
const workEntry = {
project_id: parseInt(projectId),
work_type_id: parseInt(workTypeId),
work_status_id: parseInt(workStatusId),
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
work_hours: parseFloat(workHours)
});
};
console.log('🔍 생성된 작업 항목:', workEntry);
console.log('🔍 작업 항목 상세:', {
project_id: workEntry.project_id,
work_type_id: workEntry.work_type_id,
work_status_id: workEntry.work_status_id,
error_type_id: workEntry.error_type_id,
work_hours: workEntry.work_hours
});
newWorkEntries.push(workEntry);
}
console.log('🔍 최종 수집된 작업 항목들:', newWorkEntries);
console.log('🔍 총 작업 항목 개수:', newWorkEntries.length);
try {
const submitBtn = document.getElementById('submitBtn');
@@ -481,38 +616,61 @@ async function saveWorkReport() {
const failureDetails = [];
for (const workerId of selectedWorkers) {
const workerName = workers.find(w => w.worker_id == workerId)?.worker_name || '알 수 없음';
// 서버가 기대하는 work_entries 배열 형태로 전송
const requestData = {
report_date: reportDate,
worker_id: parseInt(workerId),
work_entries: newWorkEntries,
work_entries: newWorkEntries.map(entry => ({
project_id: entry.project_id,
task_id: entry.work_type_id, // 서버에서 task_id로 기대
work_hours: entry.work_hours,
work_status_id: entry.work_status_id,
error_type_id: entry.error_type_id
})),
created_by: currentUser?.user_id || currentUser?.id
};
console.log('전송 데이터 (통합 API 사용):', requestData);
console.log('🔄 배열 형태로 전송:', requestData);
console.log('🔄 work_entries:', requestData.work_entries);
console.log('🔄 work_entries[0] 상세:', requestData.work_entries[0]);
console.log('🔄 전송 데이터 JSON:', JSON.stringify(requestData, null, 2));
try {
const result = await window.apiCall(`${window.API}/daily-work-reports`, {
method: 'POST',
body: JSON.stringify(requestData)
});
const result = await window.apiCall(`${window.API}/daily-work-reports`, 'POST', requestData);
console.log('✅ 저장 성공 (통합 API):', result);
console.log('✅ 저장 성공:', result);
totalSaved++;
} catch (error) {
console.error('❌ 저장 실패:', error);
totalFailed++;
const workerName = workers.find(w => w.worker_id == workerId)?.worker_name || '알 수 없음';
failureDetails.push(`${workerName}: ${error.message}`);
}
}
// 결과 모달 표시
if (totalSaved > 0 && totalFailed === 0) {
showMessage(`${totalSaved}명의 작업보고서가 성공적으로 저장되었습니다!`, 'success');
showSaveResultModal(
'success',
'저장 완료!',
`${totalSaved}명의 작업보고서가 성공적으로 저장되었습니다.`
);
} else if (totalSaved > 0 && totalFailed > 0) {
showMessage(`⚠️ ${totalSaved}명 성공, ${totalFailed}명 실패. 실패: ${failureDetails.join(', ')}`, 'warning');
showSaveResultModal(
'warning',
'부분 저장 완료',
`${totalSaved}명은 성공했지만 ${totalFailed}명은 실패했습니다.`,
failureDetails
);
} else {
showMessage(`❌ 모든 저장이 실패했습니다. 상세: ${failureDetails.join(', ')}`, 'error');
showSaveResultModal(
'error',
'저장 실패',
'모든 작업보고서 저장이 실패했습니다.',
failureDetails
);
}
if (totalSaved > 0) {
@@ -524,7 +682,12 @@ async function saveWorkReport() {
} catch (error) {
console.error('저장 오류:', error);
showMessage('저장 중 예기치 못한 오류가 발생했습니다: ' + error.message, 'error');
showSaveResultModal(
'error',
'저장 오류',
'저장 중 예기치 못한 오류가 발생했습니다.',
[error.message]
);
} finally {
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = false;