feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현

- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성
  - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동
  - common/ → attendance/: 근태/휴가 관련 페이지 이동
  - admin/ 정리: safety-* 파일들을 safety/로 이동

- 사이드바 네비게이션 메뉴 구현
  - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리
  - 접기/펼치기 기능 및 상태 저장
  - 관리자 전용 메뉴 자동 표시/숨김

- 날씨 API 연동 (기상청 단기예보)
  - TBM 및 navbar에 현재 날씨 표시
  - weatherService.js 추가

- 안전 체크리스트 확장
  - 기본/날씨별/작업별 체크 유형 추가
  - checklist-manage.html 페이지 추가

- 이슈 신고 시스템 구현
  - workIssueController, workIssueModel, workIssueRoutes 추가

- DB 마이그레이션 파일 추가 (실행 대기)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-02 14:27:22 +09:00
parent b6485e3140
commit 74d3a78aa3
116 changed files with 23117 additions and 294 deletions

View File

@@ -18,6 +18,10 @@ let editingWorkId = null; // 수정 중인 작업 ID
let incompleteTbms = []; // 미완료 TBM 작업 목록
let currentTab = 'tbm'; // 현재 활성 탭
// 부적합 원인 관리
let currentDefectIndex = null; // 현재 편집 중인 행 인덱스
let tempDefects = {}; // 임시 부적합 원인 저장 { index: [{ error_type_id, defect_hours, note }] }
// 작업장소 지도 관련 변수
let mapCanvas = null;
let mapCtx = null;
@@ -156,9 +160,8 @@ function renderTbmWorkList() {
<th>공정</th>
<th>작업</th>
<th>작업장소</th>
<th>작업시간<br>(시간)</th>
<th>부적합<br>(시간)</th>
<th>부적합 원인</th>
<th>작업시간</th>
<th>부적합</th>
<th>제출</th>
</tr>
</thead>
@@ -190,9 +193,8 @@ function renderTbmWorkList() {
<th>공정</th>
<th>작업</th>
<th>작업장소</th>
<th>작업시간<br>(시간)</th>
<th>부적합<br>(시간)</th>
<th>부적합 원인</th>
<th>작업시간</th>
<th>부적합</th>
<th>제출</th>
</tr>
</thead>
@@ -226,18 +228,13 @@ function renderTbmWorkList() {
</td>
<td>
<input type="hidden" id="errorHours_${index}" value="0">
<div class="time-input-trigger has-value"
id="errorHoursDisplay_${index}"
onclick="openTimePicker(${index}, 'error')">
0시간
</div>
</td>
<td>
<select class="form-input-compact" id="errorType_${index}" style="display: none;">
<option value="">선택</option>
${errorTypes.map(et => `<option value="${et.id}">${et.name}</option>`).join('')}
</select>
<span id="errorTypeNone_${index}">-</span>
<input type="hidden" id="errorType_${index}" value="">
<button type="button"
class="btn-defect-toggle"
id="defectToggle_${index}"
onclick="toggleDefectArea(${index})">
<span id="defectSummary_${index}">없음</span>
</button>
</td>
<td>
<button type="button"
@@ -247,6 +244,18 @@ function renderTbmWorkList() {
</button>
</td>
</tr>
<tr class="defect-row" id="defectRow_${index}" style="display: none;">
<td colspan="8" style="padding: 0; background: #fef3c7;">
<div class="defect-inline-area" id="defectArea_${index}">
<div class="defect-list" id="defectList_${index}">
<!-- 부적합 원인 목록 -->
</div>
<button type="button" class="btn-add-defect-inline" onclick="addInlineDefect(${index})">
+ 부적합 추가
</button>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
@@ -293,8 +302,11 @@ window.submitTbmWorkReport = async function(index) {
const tbm = incompleteTbms[index];
const totalHours = parseFloat(document.getElementById(`totalHours_${index}`).value);
const errorHours = parseFloat(document.getElementById(`errorHours_${index}`).value) || 0;
const errorTypeId = document.getElementById(`errorType_${index}`).value;
const defects = tempDefects[index] || [];
// 총 부적합 시간 계산
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null;
// 필수 필드 검증
if (!totalHours || totalHours <= 0) {
@@ -307,8 +319,10 @@ window.submitTbmWorkReport = async function(index) {
return;
}
if (errorHours > 0 && !errorTypeId) {
showMessage('부적합 처리 시간이 있는 경우 원인을 선택해주세요.', 'error');
// 부적합 원인 유효성 검사
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id);
if (invalidDefects.length > 0) {
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
return;
}
@@ -330,12 +344,12 @@ window.submitTbmWorkReport = async function(index) {
end_time: null,
total_hours: totalHours,
error_hours: errorHours,
error_type_id: errorTypeId || null,
error_type_id: errorTypeId,
work_status_id: errorHours > 0 ? 2 : 1
};
console.log('🔍 TBM 제출 데이터:', JSON.stringify(reportData, null, 2));
console.log('🔍 tbm 객체:', tbm);
console.log('🔍 부적합 원인:', defects);
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
@@ -344,6 +358,16 @@ window.submitTbmWorkReport = async function(index) {
throw new Error(response.message || '작업보고서 제출 실패');
}
// 부적합 원인이 있으면 저장
if (defects.length > 0 && response.data?.report_id) {
const validDefects = defects.filter(d => d.error_type_id && d.defect_hours > 0);
if (validDefects.length > 0) {
await window.apiCall(`/daily-work-reports/${response.data.report_id}/defects`, 'PUT', {
defects: validDefects
});
}
}
showSaveResultModal(
'success',
'작업보고서 제출 완료',
@@ -353,6 +377,9 @@ window.submitTbmWorkReport = async function(index) {
response.data.completion_status
);
// 임시 부적합 데이터 삭제
delete tempDefects[index];
// 목록 새로고침
await loadIncompleteTbms();
} catch (error) {
@@ -576,18 +603,13 @@ window.addManualWorkRow = function() {
</td>
<td>
<input type="hidden" id="errorHours_${manualIndex}" value="0">
<div class="time-input-trigger has-value"
id="errorHoursDisplay_${manualIndex}"
onclick="openTimePicker('${manualIndex}', 'error')">
0시간
</div>
</td>
<td>
<select class="form-input-compact" id="errorType_${manualIndex}" style="display: none;">
<option value="">선택</option>
${errorTypes.map(et => `<option value="${et.id}">${et.name}</option>`).join('')}
</select>
<span id="errorTypeNone_${manualIndex}">-</span>
<input type="hidden" id="errorType_${manualIndex}" value="">
<button type="button"
class="btn-defect-toggle"
id="defectToggle_${manualIndex}"
onclick="toggleDefectArea('${manualIndex}')">
<span id="defectSummary_${manualIndex}">없음</span>
</button>
</td>
<td>
<button type="button" class="btn-submit-compact" onclick="submitManualWorkReport('${manualIndex}')">
@@ -600,6 +622,26 @@ window.addManualWorkRow = function() {
`;
tbody.appendChild(newRow);
// 부적합 인라인 영역 행 추가
const defectRow = document.createElement('tr');
defectRow.className = 'defect-row';
defectRow.id = `defectRow_${manualIndex}`;
defectRow.style.display = 'none';
defectRow.innerHTML = `
<td colspan="9" style="padding: 0; background: #fef3c7;">
<div class="defect-inline-area" id="defectArea_${manualIndex}">
<div class="defect-list" id="defectList_${manualIndex}">
<!-- 부적합 원인 목록 -->
</div>
<button type="button" class="btn-add-defect-inline" onclick="addInlineDefect('${manualIndex}')">
+ 부적합 추가
</button>
</div>
</td>
`;
tbody.appendChild(defectRow);
showMessage('새 작업 행이 추가되었습니다. 정보를 입력하고 제출하세요.', 'info');
};
@@ -608,9 +650,15 @@ window.addManualWorkRow = function() {
*/
window.removeManualWorkRow = function(manualIndex) {
const row = document.querySelector(`tr[data-index="${manualIndex}"]`);
const defectRow = document.getElementById(`defectRow_${manualIndex}`);
if (row) {
row.remove();
}
if (defectRow) {
defectRow.remove();
}
// 임시 부적합 데이터도 삭제
delete tempDefects[manualIndex];
};
/**
@@ -976,8 +1024,11 @@ window.submitManualWorkReport = async function(manualIndex) {
const workplaceCategoryId = document.getElementById(`workplaceCategory_${manualIndex}`).value;
const workplaceId = document.getElementById(`workplace_${manualIndex}`).value;
const totalHours = parseFloat(document.getElementById(`totalHours_${manualIndex}`).value);
const errorHours = parseFloat(document.getElementById(`errorHours_${manualIndex}`).value) || 0;
const errorTypeId = document.getElementById(`errorType_${manualIndex}`).value;
// 부적합 원인 가져오기
const defects = tempDefects[manualIndex] || [];
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null;
// 필수 필드 검증
if (!workerId) {
@@ -1014,8 +1065,10 @@ window.submitManualWorkReport = async function(manualIndex) {
return;
}
if (errorHours > 0 && !errorTypeId) {
showMessage('부적합 처리 시간이 있는 경우 원인을 선택해주세요.', 'error');
// 부적합 원인 유효성 검사
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id);
if (invalidDefects.length > 0) {
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
return;
}
@@ -1042,13 +1095,23 @@ window.submitManualWorkReport = async function(manualIndex) {
throw new Error(response.message || '작업보고서 제출 실패');
}
// 부적합 원인이 있으면 저장
if (defects.length > 0 && response.data?.workReport_ids?.[0]) {
const validDefects = defects.filter(d => d.error_type_id && d.defect_hours > 0);
if (validDefects.length > 0) {
await window.apiCall(`/daily-work-reports/${response.data.workReport_ids[0]}/defects`, 'PUT', {
defects: validDefects
});
}
}
showSaveResultModal(
'success',
'작업보고서 제출 완료',
'작업보고서가 성공적으로 제출되었습니다.'
);
// 행 제거
// 행 제거 (부적합 임시 데이터도 함께 삭제됨)
removeManualWorkRow(manualIndex);
// 목록 새로고침
@@ -2438,17 +2501,37 @@ function updateTimeDisplay() {
*/
window.confirmTimeSelection = function() {
if (!currentEditingField) return;
const { index, type } = currentEditingField;
const { index, type, defectIndex } = currentEditingField;
// 부적합 시간 선택인 경우
if (type === 'defect') {
if (tempDefects[index] && tempDefects[index][defectIndex] !== undefined) {
tempDefects[index][defectIndex].defect_hours = currentTimeValue;
// 시간 표시 업데이트
const timeDisplay = document.getElementById(`defectTime_${index}_${defectIndex}`);
if (timeDisplay) {
timeDisplay.textContent = currentTimeValue;
}
// 요약 및 hidden 필드 업데이트
updateDefectSummary(index);
}
closeTimePicker();
return;
}
// 기존 total/error 시간 선택
const inputId = type === 'total' ? `totalHours_${index}` : `errorHours_${index}`;
const displayId = type === 'total' ? `totalHoursDisplay_${index}` : `errorHoursDisplay_${index}`;
// hidden input 값 설정
const hiddenInput = document.getElementById(inputId);
if (hiddenInput) {
hiddenInput.value = currentTimeValue;
}
// 표시 영역 업데이트
const displayDiv = document.getElementById(displayId);
if (displayDiv) {
@@ -2456,8 +2539,8 @@ window.confirmTimeSelection = function() {
displayDiv.classList.remove('placeholder');
displayDiv.classList.add('has-value');
}
// 부적합 시간 입력 시 에러 타입 토글
// 부적합 시간 입력 시 에러 타입 토글 (기존 방식 - 이제 사용안함)
if (type === 'error') {
if (index.toString().startsWith('manual_')) {
toggleManualErrorType(index);
@@ -2465,7 +2548,7 @@ window.confirmTimeSelection = function() {
calculateRegularHours(index);
}
}
closeTimePicker();
};
@@ -2477,10 +2560,200 @@ window.closeTimePicker = function() {
if (overlay) {
overlay.style.display = 'none';
}
currentEditingField = null;
currentTimeValue = 0;
// ESC 키 리스너 제거
document.removeEventListener('keydown', handleEscapeKey);
};
// =================================================================
// 부적합 원인 관리 (인라인 방식)
// =================================================================
/**
* 부적합 영역 토글
*/
window.toggleDefectArea = function(index) {
const defectRow = document.getElementById(`defectRow_${index}`);
if (!defectRow) return;
const isVisible = defectRow.style.display !== 'none';
if (isVisible) {
// 숨기기
defectRow.style.display = 'none';
} else {
// 보이기 - 부적합 원인이 없으면 자동으로 하나 추가
if (!tempDefects[index] || tempDefects[index].length === 0) {
tempDefects[index] = [{
error_type_id: '',
defect_hours: 0,
note: ''
}];
}
renderInlineDefectList(index);
defectRow.style.display = '';
}
};
/**
* 인라인 부적합 목록 렌더링
*/
function renderInlineDefectList(index) {
const listContainer = document.getElementById(`defectList_${index}`);
if (!listContainer) return;
const defects = tempDefects[index] || [];
listContainer.innerHTML = defects.map((defect, i) => `
<div class="defect-inline-item" data-defect-index="${i}">
<select class="defect-select"
onchange="updateInlineDefect('${index}', ${i}, 'error_type_id', this.value)">
<option value="">원인 선택</option>
${errorTypes.map(et => `<option value="${et.id}" ${defect.error_type_id == et.id ? 'selected' : ''}>${et.name}</option>`).join('')}
</select>
<div class="defect-time-input"
onclick="openDefectTimePicker('${index}', ${i})">
<span class="defect-time-value" id="defectTime_${index}_${i}">${defect.defect_hours || 0}</span>
<span class="defect-time-unit">시간</span>
</div>
<button type="button" class="btn-remove-defect" onclick="removeInlineDefect('${index}', ${i})"></button>
</div>
`).join('');
updateDefectSummary(index);
}
/**
* 인라인 부적합 추가
*/
window.addInlineDefect = function(index) {
if (!tempDefects[index]) {
tempDefects[index] = [];
}
tempDefects[index].push({
error_type_id: '',
defect_hours: 0,
note: ''
});
renderInlineDefectList(index);
};
/**
* 인라인 부적합 수정
*/
window.updateInlineDefect = function(index, defectIndex, field, value) {
if (tempDefects[index] && tempDefects[index][defectIndex]) {
if (field === 'defect_hours') {
tempDefects[index][defectIndex][field] = parseFloat(value) || 0;
} else {
tempDefects[index][defectIndex][field] = value;
}
updateDefectSummary(index);
updateHiddenDefectFields(index);
}
};
/**
* 인라인 부적합 삭제
*/
window.removeInlineDefect = function(index, defectIndex) {
if (tempDefects[index]) {
tempDefects[index].splice(defectIndex, 1);
// 모든 부적합이 삭제되면 영역 숨기기
if (tempDefects[index].length === 0) {
const defectRow = document.getElementById(`defectRow_${index}`);
if (defectRow) {
defectRow.style.display = 'none';
}
} else {
renderInlineDefectList(index);
}
updateDefectSummary(index);
updateHiddenDefectFields(index);
}
};
/**
* 부적합 시간 선택기 열기 (시간 선택 팝오버 재사용)
*/
window.openDefectTimePicker = function(index, defectIndex) {
currentEditingField = { index, type: 'defect', defectIndex };
// 현재 값 가져오기
const defects = tempDefects[index] || [];
currentTimeValue = defects[defectIndex]?.defect_hours || 0;
// 팝오버 표시
const overlay = document.getElementById('timePickerOverlay');
const title = document.getElementById('timePickerTitle');
title.textContent = '부적합 시간 선택';
updateTimeDisplay();
overlay.style.display = 'flex';
// ESC 키로 닫기
document.addEventListener('keydown', handleEscapeKey);
};
/**
* hidden input 필드 업데이트
*/
function updateHiddenDefectFields(index) {
const defects = tempDefects[index] || [];
// 총 부적합 시간 계산
const totalErrorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
// hidden input에 대표 error_type_id 저장 (첫 번째 값)
const errorTypeInput = document.getElementById(`errorType_${index}`);
if (errorTypeInput && defects.length > 0 && defects[0].error_type_id) {
errorTypeInput.value = defects[0].error_type_id;
} else if (errorTypeInput) {
errorTypeInput.value = '';
}
// 부적합 시간 input 업데이트
const errorHoursInput = document.getElementById(`errorHours_${index}`);
if (errorHoursInput) {
errorHoursInput.value = totalErrorHours;
}
}
/**
* 부적합 요약 텍스트 업데이트
*/
function updateDefectSummary(index) {
const summaryEl = document.getElementById(`defectSummary_${index}`);
const toggleBtn = document.getElementById(`defectToggle_${index}`);
if (!summaryEl) return;
const defects = tempDefects[index] || [];
const validDefects = defects.filter(d => d.error_type_id && d.defect_hours > 0);
if (validDefects.length === 0) {
summaryEl.textContent = '없음';
summaryEl.style.color = '#6b7280';
if (toggleBtn) toggleBtn.classList.remove('has-defect');
} else {
const totalHours = validDefects.reduce((sum, d) => sum + d.defect_hours, 0);
if (validDefects.length === 1) {
const typeName = errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합';
summaryEl.textContent = `${typeName} ${totalHours}h`;
} else {
summaryEl.textContent = `${validDefects.length}${totalHours}h`;
}
summaryEl.style.color = '#dc2626';
if (toggleBtn) toggleBtn.classList.add('has-defect');
}
// hidden 필드도 업데이트
updateHiddenDefectFields(index);
}