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

@@ -0,0 +1,795 @@
// 코드 관리 페이지 JavaScript
// 전역 변수
let workStatusTypes = [];
let errorTypes = [];
let workTypes = [];
let currentCodeType = 'work-status';
let currentEditingCode = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('🏷️ 코드 관리 페이지 초기화 시작');
initializePage();
loadAllCodes();
});
// 페이지 초기화
function initializePage() {
// 시간 업데이트 시작
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
// 사용자 정보 업데이트
updateUserInfo();
// 프로필 메뉴 토글
setupProfileMenu();
// 로그아웃 버튼
setupLogoutButton();
}
// 현재 시간 업데이트
function updateCurrentTime() {
const now = new Date();
const timeString = now.toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const timeElement = document.getElementById('timeValue');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// 사용자 정보 업데이트
function updateUserInfo() {
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
const finalUserInfo = {
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
job_type: userInfo.job_type || authUser.role || authUser.job_type,
username: authUser.username || userInfo.username
};
const userNameElement = document.getElementById('userName');
const userRoleElement = document.getElementById('userRole');
const userInitialElement = document.getElementById('userInitial');
if (userNameElement) {
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
}
if (userRoleElement) {
const roleMap = {
'leader': '그룹장',
'worker': '작업자',
'admin': '관리자',
'system': '시스템 관리자'
};
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
}
if (userInitialElement) {
const name = finalUserInfo.worker_name || '사용자';
userInitialElement.textContent = name.charAt(0);
}
}
// 프로필 메뉴 설정
function setupProfileMenu() {
const userProfile = document.getElementById('userProfile');
const profileMenu = document.getElementById('profileMenu');
if (userProfile && profileMenu) {
userProfile.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = profileMenu.style.display === 'block';
profileMenu.style.display = isVisible ? 'none' : 'block';
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', function() {
profileMenu.style.display = 'none';
});
}
}
// 로그아웃 버튼 설정
function setupLogoutButton() {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function() {
if (confirm('로그아웃 하시겠습니까?')) {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('userInfo');
window.location.href = '/index.html';
}
});
}
}
// 모든 코드 데이터 로드
async function loadAllCodes() {
try {
console.log('📊 모든 코드 데이터 로딩 시작');
await Promise.all([
loadWorkStatusTypes(),
loadErrorTypes(),
loadWorkTypes()
]);
// 현재 활성 탭 렌더링
renderCurrentTab();
} catch (error) {
console.error('코드 데이터 로딩 오류:', error);
showToast('코드 데이터를 불러오는데 실패했습니다.', 'error');
}
}
// 작업 상태 유형 로드
async function loadWorkStatusTypes() {
try {
console.log('📊 작업 상태 유형 로딩...');
const response = await apiCall('/daily-work-reports/work-status-types', 'GET');
let statusData = [];
if (response && response.success && Array.isArray(response.data)) {
statusData = response.data;
} else if (Array.isArray(response)) {
statusData = response;
}
workStatusTypes = statusData;
console.log(`✅ 작업 상태 유형 ${workStatusTypes.length}개 로드 완료`);
} catch (error) {
console.error('작업 상태 유형 로딩 오류:', error);
workStatusTypes = [];
}
}
// 오류 유형 로드
async function loadErrorTypes() {
try {
console.log('⚠️ 오류 유형 로딩...');
const response = await apiCall('/daily-work-reports/error-types', 'GET');
let errorData = [];
if (response && response.success && Array.isArray(response.data)) {
errorData = response.data;
} else if (Array.isArray(response)) {
errorData = response;
}
errorTypes = errorData;
console.log(`✅ 오류 유형 ${errorTypes.length}개 로드 완료`);
} catch (error) {
console.error('오류 유형 로딩 오류:', error);
errorTypes = [];
}
}
// 작업 유형 로드
async function loadWorkTypes() {
try {
console.log('🔧 작업 유형 로딩...');
const response = await apiCall('/daily-work-reports/work-types', 'GET');
let typeData = [];
if (response && response.success && Array.isArray(response.data)) {
typeData = response.data;
} else if (Array.isArray(response)) {
typeData = response;
}
workTypes = typeData;
console.log(`✅ 작업 유형 ${workTypes.length}개 로드 완료`);
} catch (error) {
console.error('작업 유형 로딩 오류:', error);
workTypes = [];
}
}
// 코드 탭 전환
function switchCodeTab(tabName) {
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
// 탭 콘텐츠 표시/숨김
document.querySelectorAll('.code-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tabName}-tab`).classList.add('active');
currentCodeType = tabName;
renderCurrentTab();
}
// 현재 탭 렌더링
function renderCurrentTab() {
switch (currentCodeType) {
case 'work-status':
renderWorkStatusTypes();
break;
case 'error-types':
renderErrorTypes();
break;
case 'work-types':
renderWorkTypes();
break;
}
}
// 작업 상태 유형 렌더링
function renderWorkStatusTypes() {
const grid = document.getElementById('workStatusGrid');
if (!grid) return;
if (workStatusTypes.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📊</div>
<h3>등록된 작업 상태 유형이 없습니다.</h3>
<p>"새 상태 추가" 버튼을 눌러 작업 상태를 등록해보세요.</p>
<button class="btn btn-primary" onclick="openCodeModal('work-status')">
첫 상태 추가하기
</button>
</div>
`;
updateWorkStatusStats();
return;
}
let gridHtml = '';
workStatusTypes.forEach(status => {
const isError = status.is_error === 1 || status.is_error === true;
const statusClass = isError ? 'error-status' : 'normal-status';
const statusIcon = isError ? '❌' : '✅';
const statusLabel = isError ? '오류' : '정상';
gridHtml += `
<div class="code-card ${statusClass}" onclick="editCode('work-status', ${status.id})">
<div class="code-header">
<div class="code-icon">${statusIcon}</div>
<div class="code-info">
<h3 class="code-name">${status.name}</h3>
<span class="code-label">${statusLabel}</span>
</div>
<div class="code-actions">
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('work-status', ${status.id})" title="수정">
✏️
</button>
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('work-status', ${status.id})" title="삭제">
🗑️
</button>
</div>
</div>
${status.description ? `<p class="code-description">${status.description}</p>` : ''}
<div class="code-meta">
<span class="code-date">등록: ${formatDate(status.created_at)}</span>
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
updateWorkStatusStats();
}
// 오류 유형 렌더링
function renderErrorTypes() {
const grid = document.getElementById('errorTypesGrid');
if (!grid) return;
if (errorTypes.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<h3>등록된 오류 유형이 없습니다.</h3>
<p>"새 오류 유형 추가" 버튼을 눌러 오류 유형을 등록해보세요.</p>
<button class="btn btn-primary" onclick="openCodeModal('error-types')">
첫 오류 유형 추가하기
</button>
</div>
`;
updateErrorTypesStats();
return;
}
let gridHtml = '';
errorTypes.forEach(error => {
const severityMap = {
'low': { icon: '🟢', label: '낮음', class: 'severity-low' },
'medium': { icon: '🟡', label: '보통', class: 'severity-medium' },
'high': { icon: '🟠', label: '높음', class: 'severity-high' },
'critical': { icon: '🔴', label: '심각', class: 'severity-critical' }
};
const severity = severityMap[error.severity] || severityMap.medium;
gridHtml += `
<div class="code-card error-type-card ${severity.class}" onclick="editCode('error-types', ${error.id})">
<div class="code-header">
<div class="code-icon">⚠️</div>
<div class="code-info">
<h3 class="code-name">${error.name}</h3>
<span class="code-label">${severity.icon} ${severity.label}</span>
</div>
<div class="code-actions">
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('error-types', ${error.id})" title="수정">
✏️
</button>
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('error-types', ${error.id})" title="삭제">
🗑️
</button>
</div>
</div>
${error.description ? `<p class="code-description">${error.description}</p>` : ''}
${error.solution_guide ? `<div class="solution-guide"><strong>해결 가이드:</strong><br>${error.solution_guide}</div>` : ''}
<div class="code-meta">
<span class="code-date">등록: ${formatDate(error.created_at)}</span>
${error.updated_at !== error.created_at ? `<span class="code-date">수정: ${formatDate(error.updated_at)}</span>` : ''}
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
updateErrorTypesStats();
}
// 작업 유형 렌더링
function renderWorkTypes() {
const grid = document.getElementById('workTypesGrid');
if (!grid) return;
if (workTypes.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-icon">🔧</div>
<h3>등록된 작업 유형이 없습니다.</h3>
<p>"새 작업 유형 추가" 버튼을 눌러 작업 유형을 등록해보세요.</p>
<button class="btn btn-primary" onclick="openCodeModal('work-types')">
첫 작업 유형 추가하기
</button>
</div>
`;
updateWorkTypesStats();
return;
}
let gridHtml = '';
workTypes.forEach(type => {
gridHtml += `
<div class="code-card work-type-card" onclick="editCode('work-types', ${type.id})">
<div class="code-header">
<div class="code-icon">🔧</div>
<div class="code-info">
<h3 class="code-name">${type.name}</h3>
${type.category ? `<span class="code-label">📁 ${type.category}</span>` : ''}
</div>
<div class="code-actions">
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('work-types', ${type.id})" title="수정">
✏️
</button>
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('work-types', ${type.id})" title="삭제">
🗑️
</button>
</div>
</div>
${type.description ? `<p class="code-description">${type.description}</p>` : ''}
<div class="code-meta">
<span class="code-date">등록: ${formatDate(type.created_at)}</span>
${type.updated_at !== type.created_at ? `<span class="code-date">수정: ${formatDate(type.updated_at)}</span>` : ''}
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
updateWorkTypesStats();
}
// 작업 상태 통계 업데이트
function updateWorkStatusStats() {
const total = workStatusTypes.length;
const normal = workStatusTypes.filter(s => !s.is_error).length;
const error = workStatusTypes.filter(s => s.is_error).length;
document.getElementById('workStatusCount').textContent = total;
document.getElementById('normalStatusCount').textContent = normal;
document.getElementById('errorStatusCount').textContent = error;
}
// 오류 유형 통계 업데이트
function updateErrorTypesStats() {
const total = errorTypes.length;
const critical = errorTypes.filter(e => e.severity === 'critical').length;
const high = errorTypes.filter(e => e.severity === 'high').length;
const medium = errorTypes.filter(e => e.severity === 'medium').length;
const low = errorTypes.filter(e => e.severity === 'low').length;
document.getElementById('errorTypesCount').textContent = total;
document.getElementById('criticalErrorsCount').textContent = critical;
document.getElementById('highErrorsCount').textContent = high;
document.getElementById('mediumErrorsCount').textContent = medium;
document.getElementById('lowErrorsCount').textContent = low;
}
// 작업 유형 통계 업데이트
function updateWorkTypesStats() {
const total = workTypes.length;
const categories = new Set(workTypes.map(t => t.category).filter(Boolean)).size;
document.getElementById('workTypesCount').textContent = total;
document.getElementById('workCategoriesCount').textContent = categories;
}
// 코드 모달 열기
function openCodeModal(codeType, codeData = null) {
const modal = document.getElementById('codeModal');
const modalTitle = document.getElementById('modalTitle');
const deleteBtn = document.getElementById('deleteCodeBtn');
if (!modal) return;
currentEditingCode = codeData;
// 모든 전용 필드 숨기기
document.getElementById('isErrorGroup').style.display = 'none';
document.getElementById('severityGroup').style.display = 'none';
document.getElementById('solutionGuideGroup').style.display = 'none';
document.getElementById('categoryGroup').style.display = 'none';
// 코드 유형별 설정
switch (codeType) {
case 'work-status':
modalTitle.textContent = codeData ? '작업 상태 수정' : '새 작업 상태 추가';
document.getElementById('isErrorGroup').style.display = 'block';
break;
case 'error-types':
modalTitle.textContent = codeData ? '오류 유형 수정' : '새 오류 유형 추가';
document.getElementById('severityGroup').style.display = 'block';
document.getElementById('solutionGuideGroup').style.display = 'block';
break;
case 'work-types':
modalTitle.textContent = codeData ? '작업 유형 수정' : '새 작업 유형 추가';
document.getElementById('categoryGroup').style.display = 'block';
updateCategoryList();
break;
}
document.getElementById('codeType').value = codeType;
if (codeData) {
// 수정 모드
deleteBtn.style.display = 'inline-flex';
// 폼에 데이터 채우기
document.getElementById('codeId').value = codeData.id;
document.getElementById('codeName').value = codeData.name || '';
document.getElementById('codeDescription').value = codeData.description || '';
// 코드 유형별 필드 채우기
if (codeType === 'work-status') {
document.getElementById('isError').checked = codeData.is_error === 1 || codeData.is_error === true;
} else if (codeType === 'error-types') {
document.getElementById('severity').value = codeData.severity || 'medium';
document.getElementById('solutionGuide').value = codeData.solution_guide || '';
} else if (codeType === 'work-types') {
document.getElementById('category').value = codeData.category || '';
}
} else {
// 신규 등록 모드
deleteBtn.style.display = 'none';
// 폼 초기화
document.getElementById('codeForm').reset();
document.getElementById('codeId').value = '';
document.getElementById('codeType').value = codeType;
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// 첫 번째 입력 필드에 포커스
setTimeout(() => {
document.getElementById('codeName').focus();
}, 100);
}
// 카테고리 목록 업데이트
function updateCategoryList() {
const categoryList = document.getElementById('categoryList');
if (categoryList) {
const categories = [...new Set(workTypes.map(t => t.category).filter(Boolean))].sort();
categoryList.innerHTML = categories.map(cat => `<option value="${cat}">`).join('');
}
}
// 코드 모달 닫기
function closeCodeModal() {
const modal = document.getElementById('codeModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
currentEditingCode = null;
}
}
// 코드 편집
function editCode(codeType, codeId) {
let codeData = null;
switch (codeType) {
case 'work-status':
codeData = workStatusTypes.find(s => s.id === codeId);
break;
case 'error-types':
codeData = errorTypes.find(e => e.id === codeId);
break;
case 'work-types':
codeData = workTypes.find(t => t.id === codeId);
break;
}
if (codeData) {
openCodeModal(codeType, codeData);
} else {
showToast('코드를 찾을 수 없습니다.', 'error');
}
}
// 코드 저장
async function saveCode() {
try {
const codeType = document.getElementById('codeType').value;
const codeId = document.getElementById('codeId').value;
const codeData = {
name: document.getElementById('codeName').value.trim(),
description: document.getElementById('codeDescription').value.trim() || null
};
// 필수 필드 검증
if (!codeData.name) {
showToast('이름은 필수 입력 항목입니다.', 'error');
return;
}
// 코드 유형별 추가 필드
if (codeType === 'work-status') {
codeData.is_error = document.getElementById('isError').checked ? 1 : 0;
} else if (codeType === 'error-types') {
codeData.severity = document.getElementById('severity').value;
codeData.solution_guide = document.getElementById('solutionGuide').value.trim() || null;
} else if (codeType === 'work-types') {
codeData.category = document.getElementById('category').value.trim() || null;
}
console.log('💾 저장할 코드 데이터:', codeData);
let endpoint = '';
switch (codeType) {
case 'work-status':
endpoint = '/daily-work-reports/work-status-types';
break;
case 'error-types':
endpoint = '/daily-work-reports/error-types';
break;
case 'work-types':
endpoint = '/daily-work-reports/work-types';
break;
}
let response;
if (codeId) {
// 수정
response = await apiCall(`${endpoint}/${codeId}`, 'PUT', codeData);
} else {
// 신규 등록
response = await apiCall(endpoint, 'POST', codeData);
}
if (response && (response.success || response.id)) {
const action = codeId ? '수정' : '등록';
showToast(`코드가 성공적으로 ${action}되었습니다.`, 'success');
closeCodeModal();
await loadAllCodes();
} else {
throw new Error(response?.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('코드 저장 오류:', error);
showToast(error.message || '코드 저장 중 오류가 발생했습니다.', 'error');
}
}
// 코드 삭제 확인
function confirmDeleteCode(codeType, codeId) {
let codeData = null;
let typeName = '';
switch (codeType) {
case 'work-status':
codeData = workStatusTypes.find(s => s.id === codeId);
typeName = '작업 상태';
break;
case 'error-types':
codeData = errorTypes.find(e => e.id === codeId);
typeName = '오류 유형';
break;
case 'work-types':
codeData = workTypes.find(t => t.id === codeId);
typeName = '작업 유형';
break;
}
if (!codeData) {
showToast('코드를 찾을 수 없습니다.', 'error');
return;
}
if (confirm(`"${codeData.name}" ${typeName}을 정말 삭제하시겠습니까?\n\n⚠️ 삭제된 코드는 복구할 수 없습니다.`)) {
deleteCodeById(codeType, codeId);
}
}
// 코드 삭제 (수정 모드에서)
function deleteCode() {
if (currentEditingCode) {
const codeType = document.getElementById('codeType').value;
confirmDeleteCode(codeType, currentEditingCode.id);
}
}
// 코드 삭제 실행
async function deleteCodeById(codeType, codeId) {
try {
let endpoint = '';
switch (codeType) {
case 'work-status':
endpoint = '/daily-work-reports/work-status-types';
break;
case 'error-types':
endpoint = '/daily-work-reports/error-types';
break;
case 'work-types':
endpoint = '/daily-work-reports/work-types';
break;
}
const response = await apiCall(`${endpoint}/${codeId}`, 'DELETE');
if (response && response.success) {
showToast('코드가 성공적으로 삭제되었습니다.', 'success');
closeCodeModal();
await loadAllCodes();
} else {
throw new Error(response?.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('코드 삭제 오류:', error);
showToast(error.message || '코드 삭제 중 오류가 발생했습니다.', 'error');
}
}
// 전체 새로고침
async function refreshAllCodes() {
const refreshBtn = document.querySelector('.btn-secondary');
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
refreshBtn.disabled = true;
await loadAllCodes();
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
} else {
await loadAllCodes();
}
showToast('모든 코드 데이터가 새로고침되었습니다.', 'success');
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
// 토스트 메시지 표시
function showToast(message, type = 'info') {
// 기존 토스트 제거
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// 스타일 적용
Object.assign(toast.style, {
position: 'fixed',
top: '20px',
right: '20px',
padding: '12px 24px',
borderRadius: '8px',
color: 'white',
fontWeight: '500',
zIndex: '1000',
transform: 'translateX(100%)',
transition: 'transform 0.3s ease'
});
// 타입별 배경색
const colors = {
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
toast.style.backgroundColor = colors[type] || colors.info;
document.body.appendChild(toast);
// 애니메이션
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
// 자동 제거
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, 3000);
}
// 전역 함수로 노출
window.switchCodeTab = switchCodeTab;
window.openCodeModal = openCodeModal;
window.closeCodeModal = closeCodeModal;
window.editCode = editCode;
window.saveCode = saveCode;
window.deleteCode = deleteCode;
window.confirmDeleteCode = confirmDeleteCode;
window.refreshAllCodes = refreshAllCodes;