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:
@@ -246,6 +246,9 @@ function renderUsersTable() {
|
||||
권한
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="action-btn reset-pw" onclick="resetPassword(${user.user_id}, '${user.username}')" title="비밀번호 000000으로 초기화">
|
||||
비번초기화
|
||||
</button>
|
||||
<button class="action-btn toggle" onclick="toggleUserStatus(${user.user_id})">
|
||||
${user.is_active ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
@@ -410,6 +413,27 @@ function closeDeleteModal() {
|
||||
currentEditingUser = null;
|
||||
}
|
||||
|
||||
// ========== 비밀번호 초기화 ========== //
|
||||
async function resetPassword(userId, username) {
|
||||
if (!confirm(`${username} 사용자의 비밀번호를 000000으로 초기화하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/users/${userId}/reset-password`, 'POST');
|
||||
|
||||
if (response.success) {
|
||||
showToast(`${username}의 비밀번호가 000000으로 초기화되었습니다.`, 'success');
|
||||
} else {
|
||||
showToast(response.message || '비밀번호 초기화에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('비밀번호 초기화 오류:', error);
|
||||
showToast('비밀번호 초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
window.resetPassword = resetPassword;
|
||||
|
||||
// ========== 사용자 CRUD ========== //
|
||||
async function saveUser() {
|
||||
try {
|
||||
@@ -417,8 +441,7 @@ async function saveUser() {
|
||||
name: elements.userNameInput?.value,
|
||||
username: elements.userIdInput?.value,
|
||||
role: elements.userRoleSelect?.value, // HTML select value는 이미 'admin' 또는 'user'
|
||||
email: elements.userEmailInput?.value,
|
||||
phone: elements.userPhoneInput?.value
|
||||
email: elements.userEmailInput?.value
|
||||
};
|
||||
|
||||
console.log('저장할 데이터:', formData);
|
||||
@@ -647,24 +670,30 @@ function renderPageAccessList(userRole) {
|
||||
}
|
||||
|
||||
// 페이지 권한 저장
|
||||
async function savePageAccess(userId) {
|
||||
async function savePageAccess(userId, containerId = null) {
|
||||
try {
|
||||
const checkboxes = document.querySelectorAll('.page-access-checkbox:not([disabled])');
|
||||
const pageAccessData = [];
|
||||
|
||||
// 특정 컨테이너가 지정되면 그 안에서만 체크박스 선택
|
||||
const container = containerId ? document.getElementById(containerId) : document;
|
||||
const checkboxes = container.querySelectorAll('.page-access-checkbox:not([disabled])');
|
||||
|
||||
// 중복 page_id 제거 (Map 사용)
|
||||
const pageAccessMap = new Map();
|
||||
checkboxes.forEach(checkbox => {
|
||||
pageAccessData.push({
|
||||
page_id: parseInt(checkbox.dataset.pageId),
|
||||
const pageId = parseInt(checkbox.dataset.pageId);
|
||||
pageAccessMap.set(pageId, {
|
||||
page_id: pageId,
|
||||
can_access: checkbox.checked ? 1 : 0
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const pageAccessData = Array.from(pageAccessMap.values());
|
||||
|
||||
console.log('📤 페이지 권한 저장:', userId, pageAccessData);
|
||||
|
||||
|
||||
await apiCall(`/users/${userId}/page-access`, 'PUT', {
|
||||
pageAccess: pageAccessData
|
||||
});
|
||||
|
||||
|
||||
console.log('✅ 페이지 권한 저장 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 저장 오류:', error);
|
||||
@@ -845,12 +874,13 @@ async function savePageAccessFromModal() {
|
||||
}
|
||||
|
||||
try {
|
||||
await savePageAccess(currentPageAccessUser.user_id);
|
||||
// 모달 컨테이너 지정
|
||||
await savePageAccess(currentPageAccessUser.user_id, 'pageAccessModalList');
|
||||
showToast('페이지 권한이 저장되었습니다.', 'success');
|
||||
|
||||
|
||||
// 캐시 삭제 (사용자가 다시 로그인하거나 페이지 새로고침 필요)
|
||||
localStorage.removeItem('userPageAccess');
|
||||
|
||||
|
||||
closePageAccessModal();
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 저장 오류:', error);
|
||||
|
||||
@@ -246,4 +246,4 @@ setInterval(() => {
|
||||
}, config.app.tokenRefreshInterval); // 5분마다 확인
|
||||
|
||||
// ES6 모듈 export
|
||||
export { API_URL as API_BASE_URL };
|
||||
export { API_URL as API_BASE_URL, API_URL as API, apiCall, getAuthHeaders };
|
||||
@@ -79,7 +79,7 @@ async function checkPageAccess(pageKey) {
|
||||
|
||||
// 캐시가 없으면 API 호출
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, {
|
||||
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -26,10 +26,12 @@ export const config = {
|
||||
|
||||
// 공용 컴포넌트 경로 설정
|
||||
components: {
|
||||
// 사이드바 HTML 파일 경로
|
||||
// 사이드바 HTML 파일 경로 (구버전)
|
||||
sidebar: '/components/sidebar.html',
|
||||
// 네비게이션 바 HTML 파일 경로 (예상)
|
||||
navbar: '/components/navbar.html',
|
||||
// 새 사이드바 네비게이션 (카테고리별)
|
||||
'sidebar-nav': '/components/sidebar-nav.html',
|
||||
// 네비게이션 바 HTML 파일 경로
|
||||
navbar: '/components/navbar.html',
|
||||
},
|
||||
|
||||
// 애플리케이션 관련 기타 설정
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ async function processNavbarDom(doc) {
|
||||
async function filterMenuByPageAccess(doc, currentUser) {
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
|
||||
// Admin은 모든 메뉴 표시
|
||||
if (userRole === 'admin' || userRole === 'system') {
|
||||
// Admin은 모든 메뉴 표시 + .admin-only 요소 활성화
|
||||
if (userRole === 'admin' || userRole === 'system admin') {
|
||||
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ async function filterMenuByPageAccess(doc, currentUser) {
|
||||
|
||||
// 캐시가 없으면 API 호출
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, {
|
||||
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -153,14 +154,118 @@ function setupNavbarEvents() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 시간을 업데이트하는 함수
|
||||
* 현재 날짜와 시간을 업데이트하는 함수
|
||||
*/
|
||||
function updateTime() {
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
const now = new Date();
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
function updateDateTime() {
|
||||
const now = new Date();
|
||||
|
||||
// 시간 업데이트
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
}
|
||||
|
||||
// 날짜 업데이트
|
||||
const dateElement = document.getElementById('dateValue');
|
||||
if (dateElement) {
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const day = days[now.getDay()];
|
||||
dateElement.textContent = `${month}월 ${date}일 (${day})`;
|
||||
}
|
||||
}
|
||||
|
||||
// 날씨 아이콘 매핑
|
||||
const WEATHER_ICONS = {
|
||||
clear: '☀️',
|
||||
rain: '🌧️',
|
||||
snow: '❄️',
|
||||
heat: '🔥',
|
||||
cold: '🥶',
|
||||
wind: '💨',
|
||||
fog: '🌫️',
|
||||
dust: '😷',
|
||||
cloudy: '⛅',
|
||||
overcast: '☁️'
|
||||
};
|
||||
|
||||
// 날씨 조건명
|
||||
const WEATHER_NAMES = {
|
||||
clear: '맑음',
|
||||
rain: '비',
|
||||
snow: '눈',
|
||||
heat: '폭염',
|
||||
cold: '한파',
|
||||
wind: '강풍',
|
||||
fog: '안개',
|
||||
dust: '미세먼지',
|
||||
cloudy: '구름많음',
|
||||
overcast: '흐림'
|
||||
};
|
||||
|
||||
/**
|
||||
* 날씨 정보를 가져와서 업데이트하는 함수
|
||||
*/
|
||||
async function updateWeather() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await fetch(`${window.API_BASE_URL}/tbm/weather/current`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('날씨 API 호출 실패');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
const { temperature, conditions, weatherData } = result.data;
|
||||
|
||||
// 온도 표시
|
||||
const tempElement = document.getElementById('weatherTemp');
|
||||
if (tempElement && temperature !== null && temperature !== undefined) {
|
||||
tempElement.textContent = `${Math.round(temperature)}°C`;
|
||||
}
|
||||
|
||||
// 날씨 아이콘 및 설명
|
||||
const iconElement = document.getElementById('weatherIcon');
|
||||
const descElement = document.getElementById('weatherDesc');
|
||||
|
||||
if (conditions && conditions.length > 0) {
|
||||
const primaryCondition = conditions[0];
|
||||
if (iconElement) {
|
||||
iconElement.textContent = WEATHER_ICONS[primaryCondition] || '🌤️';
|
||||
}
|
||||
if (descElement) {
|
||||
descElement.textContent = WEATHER_NAMES[primaryCondition] || '맑음';
|
||||
}
|
||||
} else {
|
||||
if (iconElement) iconElement.textContent = '☀️';
|
||||
if (descElement) descElement.textContent = '맑음';
|
||||
}
|
||||
|
||||
// 날씨 섹션 표시
|
||||
const weatherSection = document.getElementById('weatherSection');
|
||||
if (weatherSection) {
|
||||
weatherSection.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('날씨 정보 로드 실패:', error.message);
|
||||
// 실패해도 기본값 표시
|
||||
const descElement = document.getElementById('weatherDesc');
|
||||
if (descElement) {
|
||||
descElement.textContent = '날씨 정보 없음';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 로직: DOMContentLoaded 시 실행
|
||||
@@ -168,12 +273,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (getUser()) {
|
||||
// 1. 컴포넌트 로드 및 DOM 수정
|
||||
await loadComponent('navbar', '#navbar-container', processNavbarDom);
|
||||
|
||||
|
||||
// 2. DOM에 삽입된 후에 이벤트 리스너 설정
|
||||
setupNavbarEvents();
|
||||
|
||||
// 3. 실시간 시간 업데이트 시작
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
// 3. 실시간 날짜/시간 업데이트 시작
|
||||
updateDateTime();
|
||||
setInterval(updateDateTime, 1000);
|
||||
|
||||
// 4. 날씨 정보 로드 (10분마다 갱신)
|
||||
updateWeather();
|
||||
setInterval(updateWeather, 10 * 60 * 1000);
|
||||
}
|
||||
});
|
||||
@@ -1,47 +1,191 @@
|
||||
// /js/load-sidebar.js
|
||||
// 사이드바 네비게이션 로더 및 컨트롤러
|
||||
|
||||
import { getUser } from './auth.js';
|
||||
import { loadComponent } from './component-loader.js';
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 사이드바 메뉴 항목을 필터링하는 DOM 프로세서입니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* 사이드바 DOM을 사용자 권한에 맞게 처리
|
||||
*/
|
||||
function filterSidebarByRole(doc) {
|
||||
async function processSidebarDom(doc) {
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return; // 비로그인 상태면 필터링하지 않음
|
||||
if (!currentUser) return;
|
||||
|
||||
const userRole = currentUser.role;
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system';
|
||||
|
||||
// 'system' 역할은 모든 메뉴를 볼 수 있으므로 필터링하지 않음
|
||||
if (userRole === 'system') {
|
||||
return;
|
||||
// 1. 관리자 전용 메뉴 표시/숨김
|
||||
if (isAdmin) {
|
||||
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
|
||||
} else {
|
||||
// 비관리자: 페이지 접근 권한에 따라 메뉴 필터링
|
||||
await filterMenuByPageAccess(doc, currentUser);
|
||||
}
|
||||
|
||||
// 역할과 그에 해당하는 클래스 선택자 매핑
|
||||
const roleClassMap = {
|
||||
admin: '.admin-only',
|
||||
leader: '.leader-only',
|
||||
user: '.user-only',
|
||||
support: '.support-only'
|
||||
};
|
||||
|
||||
// 모든 역할 기반 선택자를 가져옴
|
||||
const allRoleSelectors = Object.values(roleClassMap).join(', ');
|
||||
const allRoleElements = doc.querySelectorAll(allRoleSelectors);
|
||||
// 2. 현재 페이지 활성화
|
||||
highlightCurrentPage(doc);
|
||||
|
||||
allRoleElements.forEach(el => {
|
||||
// 요소가 현재 사용자 역할에 해당하는 클래스를 가지고 있는지 확인
|
||||
const userRoleSelector = roleClassMap[userRole];
|
||||
if (!userRoleSelector || !el.matches(userRoleSelector)) {
|
||||
el.remove();
|
||||
// 3. 저장된 상태 복원
|
||||
restoreSidebarState(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 페이지 접근 권한에 따라 메뉴 필터링
|
||||
*/
|
||||
async function filterMenuByPageAccess(doc, currentUser) {
|
||||
try {
|
||||
const cached = localStorage.getItem('userPageAccess');
|
||||
let accessiblePages = null;
|
||||
|
||||
if (cached) {
|
||||
const cacheData = JSON.parse(cached);
|
||||
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
|
||||
accessiblePages = cacheData.pages;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
accessiblePages = data.data.pageAccess || [];
|
||||
|
||||
localStorage.setItem('userPageAccess', JSON.stringify({
|
||||
pages: accessiblePages,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
|
||||
const accessiblePageKeys = accessiblePages
|
||||
.filter(p => p.can_access === 1)
|
||||
.map(p => p.page_key);
|
||||
|
||||
// 메뉴 항목 필터링
|
||||
const menuItems = doc.querySelectorAll('[data-page-key]');
|
||||
menuItems.forEach(item => {
|
||||
const pageKey = item.getAttribute('data-page-key');
|
||||
|
||||
// 대시보드와 프로필은 항상 표시
|
||||
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한 없으면 숨김
|
||||
if (!accessiblePageKeys.includes(pageKey)) {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 관리자 전용 카테고리 제거
|
||||
doc.querySelectorAll('.nav-category.admin-only').forEach(el => el.remove());
|
||||
|
||||
} catch (error) {
|
||||
console.error('사이드바 메뉴 필터링 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지 하이라이트
|
||||
*/
|
||||
function highlightCurrentPage(doc) {
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
doc.querySelectorAll('.nav-item').forEach(item => {
|
||||
const href = item.getAttribute('href');
|
||||
if (href && currentPath.includes(href.replace(/^\//, ''))) {
|
||||
item.classList.add('active');
|
||||
|
||||
// 부모 카테고리 열기
|
||||
const category = item.closest('.nav-category');
|
||||
if (category) {
|
||||
category.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 로드 시 사이드바를 로드하고 역할에 따라 필터링합니다.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 'getUser'를 통해 로그인 상태 확인. 비로그인 시 아무 작업도 하지 않음.
|
||||
if (getUser()) {
|
||||
loadComponent('sidebar', '#sidebar-container', filterSidebarByRole);
|
||||
/**
|
||||
* 사이드바 상태 복원
|
||||
*/
|
||||
function restoreSidebarState(doc) {
|
||||
const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
|
||||
const sidebar = doc.querySelector('.sidebar-nav');
|
||||
|
||||
if (isCollapsed && sidebar) {
|
||||
sidebar.classList.add('collapsed');
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
});
|
||||
|
||||
// 확장된 카테고리 복원
|
||||
const expandedCategories = JSON.parse(localStorage.getItem('sidebarExpanded') || '[]');
|
||||
expandedCategories.forEach(category => {
|
||||
const el = doc.querySelector(`[data-category="${category}"]`);
|
||||
if (el) el.classList.add('expanded');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드바 이벤트 설정
|
||||
*/
|
||||
function setupSidebarEvents() {
|
||||
const sidebar = document.getElementById('sidebarNav');
|
||||
const toggle = document.getElementById('sidebarToggle');
|
||||
|
||||
if (!sidebar || !toggle) return;
|
||||
|
||||
// 토글 버튼 클릭
|
||||
toggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
document.body.classList.toggle('sidebar-collapsed');
|
||||
|
||||
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
|
||||
});
|
||||
|
||||
// 카테고리 헤더 클릭
|
||||
sidebar.querySelectorAll('.nav-category-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const category = header.closest('.nav-category');
|
||||
category.classList.toggle('expanded');
|
||||
|
||||
// 상태 저장
|
||||
const expanded = [];
|
||||
sidebar.querySelectorAll('.nav-category.expanded').forEach(cat => {
|
||||
const categoryName = cat.getAttribute('data-category');
|
||||
if (categoryName) expanded.push(categoryName);
|
||||
});
|
||||
localStorage.setItem('sidebarExpanded', JSON.stringify(expanded));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드바 초기화
|
||||
*/
|
||||
async function initSidebar() {
|
||||
// 사이드바 컨테이너가 없으면 생성
|
||||
let container = document.getElementById('sidebar-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'sidebar-container';
|
||||
document.body.prepend(container);
|
||||
}
|
||||
|
||||
if (getUser()) {
|
||||
await loadComponent('sidebar-nav', '#sidebar-container', processSidebarDom);
|
||||
document.body.classList.add('has-sidebar');
|
||||
setupSidebarEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// DOMContentLoaded 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', initSidebar);
|
||||
|
||||
export { initSidebar };
|
||||
718
web-ui/js/safety-checklist-manage.js
Normal file
718
web-ui/js/safety-checklist-manage.js
Normal file
@@ -0,0 +1,718 @@
|
||||
/**
|
||||
* 안전 체크리스트 관리 페이지 스크립트
|
||||
*
|
||||
* 3가지 유형의 체크리스트 항목을 관리:
|
||||
* 1. 기본 사항 - 항상 표시
|
||||
* 2. 날씨별 - 날씨 조건에 따라 표시
|
||||
* 3. 작업별 - 선택한 작업에 따라 표시
|
||||
*
|
||||
* @since 2026-02-02
|
||||
*/
|
||||
|
||||
import { apiCall } from './api-config.js';
|
||||
|
||||
// 전역 상태
|
||||
let allChecks = [];
|
||||
let weatherConditions = [];
|
||||
let workTypes = [];
|
||||
let tasks = [];
|
||||
let currentTab = 'basic';
|
||||
let editingCheckId = null;
|
||||
|
||||
// 카테고리 정보
|
||||
const CATEGORIES = {
|
||||
PPE: { name: 'PPE (개인보호장비)', icon: '🦺' },
|
||||
EQUIPMENT: { name: 'EQUIPMENT (장비점검)', icon: '🔧' },
|
||||
ENVIRONMENT: { name: 'ENVIRONMENT (작업환경)', icon: '🏗️' },
|
||||
EMERGENCY: { name: 'EMERGENCY (비상대응)', icon: '🚨' },
|
||||
WEATHER: { name: 'WEATHER (날씨)', icon: '🌤️' },
|
||||
TASK: { name: 'TASK (작업)', icon: '📋' }
|
||||
};
|
||||
|
||||
// 날씨 아이콘 매핑
|
||||
const WEATHER_ICONS = {
|
||||
clear: '☀️',
|
||||
rain: '🌧️',
|
||||
snow: '❄️',
|
||||
heat: '🔥',
|
||||
cold: '🥶',
|
||||
wind: '💨',
|
||||
fog: '🌫️',
|
||||
dust: '😷'
|
||||
};
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
*/
|
||||
async function initPage() {
|
||||
try {
|
||||
console.log('📋 안전 체크리스트 관리 페이지 초기화...');
|
||||
|
||||
await Promise.all([
|
||||
loadAllChecks(),
|
||||
loadWeatherConditions(),
|
||||
loadWorkTypes()
|
||||
]);
|
||||
|
||||
renderCurrentTab();
|
||||
console.log('✅ 초기화 완료. 체크항목:', allChecks.length, '개');
|
||||
} catch (error) {
|
||||
console.error('초기화 실패:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// DOMContentLoaded 이벤트
|
||||
document.addEventListener('DOMContentLoaded', initPage);
|
||||
|
||||
/**
|
||||
* 모든 안전 체크 항목 로드
|
||||
*/
|
||||
async function loadAllChecks() {
|
||||
try {
|
||||
const response = await apiCall('/tbm/safety-checks');
|
||||
if (response && response.success) {
|
||||
allChecks = response.data || [];
|
||||
console.log('✅ 체크 항목 로드:', allChecks.length, '개');
|
||||
} else {
|
||||
console.warn('체크 항목 응답 실패:', response);
|
||||
allChecks = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('체크 항목 로드 실패:', error);
|
||||
allChecks = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 조건 목록 로드
|
||||
*/
|
||||
async function loadWeatherConditions() {
|
||||
try {
|
||||
const response = await apiCall('/tbm/weather/conditions');
|
||||
if (response && response.success) {
|
||||
weatherConditions = response.data || [];
|
||||
populateWeatherSelects();
|
||||
console.log('✅ 날씨 조건 로드:', weatherConditions.length, '개');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('날씨 조건 로드 실패:', error);
|
||||
weatherConditions = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정(작업 유형) 목록 로드
|
||||
*/
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
const response = await apiCall('/daily-work-reports/work-types');
|
||||
if (response && response.success) {
|
||||
workTypes = response.data || [];
|
||||
populateWorkTypeSelects();
|
||||
console.log('✅ 공정 목록 로드:', workTypes.length, '개');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('공정 목록 로드 실패:', error);
|
||||
workTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 조건 셀렉트 박스 채우기
|
||||
*/
|
||||
function populateWeatherSelects() {
|
||||
const filterSelect = document.getElementById('weatherFilter');
|
||||
const modalSelect = document.getElementById('weatherCondition');
|
||||
|
||||
const options = weatherConditions.map(wc =>
|
||||
`<option value="${wc.condition_code}">${WEATHER_ICONS[wc.condition_code] || ''} ${wc.condition_name}</option>`
|
||||
).join('');
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.innerHTML = `<option value="">모든 날씨 조건</option>${options}`;
|
||||
}
|
||||
|
||||
if (modalSelect) {
|
||||
modalSelect.innerHTML = options || '<option value="">날씨 조건 없음</option>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 셀렉트 박스 채우기
|
||||
*/
|
||||
function populateWorkTypeSelects() {
|
||||
const filterSelect = document.getElementById('workTypeFilter');
|
||||
const modalSelect = document.getElementById('modalWorkType');
|
||||
|
||||
const options = workTypes.map(wt =>
|
||||
`<option value="${wt.work_type_id}">${wt.work_type_name}</option>`
|
||||
).join('');
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.innerHTML = `<option value="">공정 선택</option>${options}`;
|
||||
}
|
||||
|
||||
if (modalSelect) {
|
||||
modalSelect.innerHTML = `<option value="">공정 선택</option>${options}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
currentTab = tabName;
|
||||
|
||||
// 탭 버튼 상태 업데이트
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
// 탭 콘텐츠 표시/숨김
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.toggle('active', content.id === `${tabName}Tab`);
|
||||
});
|
||||
|
||||
renderCurrentTab();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 탭 렌더링
|
||||
*/
|
||||
function renderCurrentTab() {
|
||||
switch (currentTab) {
|
||||
case 'basic':
|
||||
renderBasicChecks();
|
||||
break;
|
||||
case 'weather':
|
||||
renderWeatherChecks();
|
||||
break;
|
||||
case 'task':
|
||||
renderTaskChecks();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 체크 항목 렌더링
|
||||
*/
|
||||
function renderBasicChecks() {
|
||||
const container = document.getElementById('basicChecklistContainer');
|
||||
const basicChecks = allChecks.filter(c => c.check_type === 'basic');
|
||||
|
||||
console.log('기본 체크항목:', basicChecks.length, '개');
|
||||
|
||||
if (basicChecks.length === 0) {
|
||||
container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const grouped = groupByCategory(basicChecks);
|
||||
|
||||
container.innerHTML = Object.entries(grouped).map(([category, items]) =>
|
||||
renderChecklistGroup(category, items)
|
||||
).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨별 체크 항목 렌더링
|
||||
*/
|
||||
function renderWeatherChecks() {
|
||||
const container = document.getElementById('weatherChecklistContainer');
|
||||
const filterValue = document.getElementById('weatherFilter')?.value;
|
||||
|
||||
let weatherChecks = allChecks.filter(c => c.check_type === 'weather');
|
||||
|
||||
if (filterValue) {
|
||||
weatherChecks = weatherChecks.filter(c => c.weather_condition === filterValue);
|
||||
}
|
||||
|
||||
if (weatherChecks.length === 0) {
|
||||
container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 날씨 조건별로 그룹화
|
||||
const grouped = groupByWeather(weatherChecks);
|
||||
|
||||
container.innerHTML = Object.entries(grouped).map(([condition, items]) => {
|
||||
const conditionInfo = weatherConditions.find(wc => wc.condition_code === condition);
|
||||
const icon = WEATHER_ICONS[condition] || '🌤️';
|
||||
const name = conditionInfo?.condition_name || condition;
|
||||
|
||||
return renderChecklistGroup(`${icon} ${name}`, items, condition);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업별 체크 항목 렌더링
|
||||
*/
|
||||
function renderTaskChecks() {
|
||||
const container = document.getElementById('taskChecklistContainer');
|
||||
const workTypeId = document.getElementById('workTypeFilter')?.value;
|
||||
const taskId = document.getElementById('taskFilter')?.value;
|
||||
|
||||
let taskChecks = allChecks.filter(c => c.check_type === 'task');
|
||||
|
||||
if (taskId) {
|
||||
taskChecks = taskChecks.filter(c => c.task_id == taskId);
|
||||
} else if (workTypeId && tasks.length > 0) {
|
||||
const workTypeTasks = tasks.filter(t => t.work_type_id == workTypeId);
|
||||
const taskIds = workTypeTasks.map(t => t.task_id);
|
||||
taskChecks = taskChecks.filter(c => taskIds.includes(c.task_id));
|
||||
}
|
||||
|
||||
if (taskChecks.length === 0) {
|
||||
container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업별로 그룹화
|
||||
const grouped = groupByTask(taskChecks);
|
||||
|
||||
container.innerHTML = Object.entries(grouped).map(([taskId, items]) => {
|
||||
const task = tasks.find(t => t.task_id == taskId);
|
||||
const taskName = task?.task_name || `작업 ${taskId}`;
|
||||
|
||||
return renderChecklistGroup(`📋 ${taskName}`, items, null, taskId);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 그룹화
|
||||
*/
|
||||
function groupByCategory(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const category = check.check_category || 'OTHER';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 조건별 그룹화
|
||||
*/
|
||||
function groupByWeather(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const condition = check.weather_condition || 'other';
|
||||
if (!acc[condition]) acc[condition] = [];
|
||||
acc[condition].push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업별 그룹화
|
||||
*/
|
||||
function groupByTask(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const taskId = check.task_id || 0;
|
||||
if (!acc[taskId]) acc[taskId] = [];
|
||||
acc[taskId].push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 그룹 렌더링
|
||||
*/
|
||||
function renderChecklistGroup(title, items, weatherCondition = null, taskId = null) {
|
||||
const categoryInfo = CATEGORIES[title] || { name: title, icon: '' };
|
||||
const displayTitle = categoryInfo.name !== title ? categoryInfo.name : title;
|
||||
const icon = categoryInfo.icon || '';
|
||||
|
||||
// 표시 순서로 정렬
|
||||
items.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
|
||||
|
||||
return `
|
||||
<div class="checklist-group">
|
||||
<div class="group-header">
|
||||
<div class="group-title">
|
||||
<span class="group-icon">${icon}</span>
|
||||
<span>${displayTitle}</span>
|
||||
</div>
|
||||
<span class="group-count">${items.length}개</span>
|
||||
</div>
|
||||
<div class="checklist-items">
|
||||
${items.map(item => renderChecklistItem(item)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 항목 렌더링
|
||||
*/
|
||||
function renderChecklistItem(item) {
|
||||
const requiredBadge = item.is_required
|
||||
? '<span class="item-badge badge-required">필수</span>'
|
||||
: '<span class="item-badge badge-optional">선택</span>';
|
||||
|
||||
return `
|
||||
<div class="checklist-item" data-check-id="${item.check_id}">
|
||||
<div class="item-info">
|
||||
<div class="item-name">${item.check_item}</div>
|
||||
<div class="item-meta">
|
||||
${requiredBadge}
|
||||
${item.description ? `<span>${item.description}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="btn-icon btn-edit" onclick="openEditModal(${item.check_id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-icon btn-delete" onclick="confirmDelete(${item.check_id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 상태 렌더링
|
||||
*/
|
||||
function renderEmptyState(message) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📋</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 필터 변경
|
||||
*/
|
||||
function filterByWeather() {
|
||||
renderWeatherChecks();
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 필터 변경
|
||||
*/
|
||||
async function filterByWorkType() {
|
||||
const workTypeId = document.getElementById('workTypeFilter')?.value;
|
||||
const taskSelect = document.getElementById('taskFilter');
|
||||
|
||||
// workTypeId가 없거나 빈 문자열이면 early return
|
||||
if (!workTypeId || workTypeId === '' || workTypeId === 'undefined') {
|
||||
if (taskSelect) {
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>';
|
||||
}
|
||||
tasks = [];
|
||||
renderTaskChecks();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/tasks/work-type/${workTypeId}`);
|
||||
if (response && response.success) {
|
||||
tasks = response.data || [];
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
||||
tasks.map(t => `<option value="${t.task_id}">${t.task_name}</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업 목록 로드 실패:', error);
|
||||
tasks = [];
|
||||
}
|
||||
|
||||
renderTaskChecks();
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 필터 변경
|
||||
*/
|
||||
function filterByTask() {
|
||||
renderTaskChecks();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달의 작업 목록 로드
|
||||
*/
|
||||
async function loadModalTasks() {
|
||||
const workTypeId = document.getElementById('modalWorkType')?.value;
|
||||
const taskSelect = document.getElementById('modalTask');
|
||||
|
||||
// workTypeId가 없거나 빈 문자열이면 early return
|
||||
if (!workTypeId || workTypeId === '' || workTypeId === 'undefined') {
|
||||
if (taskSelect) {
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/tasks/work-type/${workTypeId}`);
|
||||
if (response && response.success) {
|
||||
const modalTasks = response.data || [];
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
||||
modalTasks.map(t => `<option value="${t.task_id}">${t.task_name}</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 필드 토글
|
||||
*/
|
||||
function toggleConditionalFields() {
|
||||
const checkType = document.querySelector('input[name="checkType"]:checked')?.value;
|
||||
|
||||
document.getElementById('basicFields').classList.toggle('show', checkType === 'basic');
|
||||
document.getElementById('weatherFields').classList.toggle('show', checkType === 'weather');
|
||||
document.getElementById('taskFields').classList.toggle('show', checkType === 'task');
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가 모달 열기
|
||||
*/
|
||||
function openAddModal() {
|
||||
editingCheckId = null;
|
||||
document.getElementById('modalTitle').textContent = '체크 항목 추가';
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('checkForm').reset();
|
||||
document.getElementById('checkId').value = '';
|
||||
|
||||
// 현재 탭에 맞는 유형 선택
|
||||
const typeRadio = document.querySelector(`input[name="checkType"][value="${currentTab}"]`);
|
||||
if (typeRadio) {
|
||||
typeRadio.checked = true;
|
||||
}
|
||||
|
||||
toggleConditionalFields();
|
||||
showModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 모달 열기
|
||||
*/
|
||||
async function openEditModal(checkId) {
|
||||
editingCheckId = checkId;
|
||||
const check = allChecks.find(c => c.check_id === checkId);
|
||||
|
||||
if (!check) {
|
||||
showToast('항목을 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('modalTitle').textContent = '체크 항목 수정';
|
||||
document.getElementById('checkId').value = checkId;
|
||||
|
||||
// 유형 선택
|
||||
const typeRadio = document.querySelector(`input[name="checkType"][value="${check.check_type}"]`);
|
||||
if (typeRadio) {
|
||||
typeRadio.checked = true;
|
||||
}
|
||||
|
||||
toggleConditionalFields();
|
||||
|
||||
// 카테고리
|
||||
if (check.check_type === 'basic') {
|
||||
document.getElementById('checkCategory').value = check.check_category || 'PPE';
|
||||
}
|
||||
|
||||
// 날씨 조건
|
||||
if (check.check_type === 'weather') {
|
||||
document.getElementById('weatherCondition').value = check.weather_condition || '';
|
||||
}
|
||||
|
||||
// 작업
|
||||
if (check.check_type === 'task' && check.task_id) {
|
||||
// 먼저 공정 찾기 (task를 통해)
|
||||
const task = tasks.find(t => t.task_id === check.task_id);
|
||||
if (task) {
|
||||
document.getElementById('modalWorkType').value = task.work_type_id;
|
||||
await loadModalTasks();
|
||||
document.getElementById('modalTask').value = check.task_id;
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 필드
|
||||
document.getElementById('checkItem').value = check.check_item || '';
|
||||
document.getElementById('checkDescription').value = check.description || '';
|
||||
document.getElementById('isRequired').checked = check.is_required === 1 || check.is_required === true;
|
||||
document.getElementById('displayOrder').value = check.display_order || 0;
|
||||
|
||||
showModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 표시
|
||||
*/
|
||||
function showModal() {
|
||||
document.getElementById('checkModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 닫기
|
||||
*/
|
||||
function closeModal() {
|
||||
document.getElementById('checkModal').style.display = 'none';
|
||||
editingCheckId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 항목 저장
|
||||
*/
|
||||
async function saveCheck() {
|
||||
const checkType = document.querySelector('input[name="checkType"]:checked')?.value;
|
||||
const checkItem = document.getElementById('checkItem').value.trim();
|
||||
|
||||
if (!checkItem) {
|
||||
showToast('체크 항목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
check_type: checkType,
|
||||
check_item: checkItem,
|
||||
description: document.getElementById('checkDescription').value.trim() || null,
|
||||
is_required: document.getElementById('isRequired').checked,
|
||||
display_order: parseInt(document.getElementById('displayOrder').value) || 0
|
||||
};
|
||||
|
||||
// 유형별 추가 데이터
|
||||
switch (checkType) {
|
||||
case 'basic':
|
||||
data.check_category = document.getElementById('checkCategory').value;
|
||||
break;
|
||||
case 'weather':
|
||||
data.check_category = 'WEATHER';
|
||||
data.weather_condition = document.getElementById('weatherCondition').value;
|
||||
if (!data.weather_condition) {
|
||||
showToast('날씨 조건을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'task':
|
||||
data.check_category = 'TASK';
|
||||
data.task_id = document.getElementById('modalTask').value;
|
||||
if (!data.task_id) {
|
||||
showToast('작업을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingCheckId) {
|
||||
// 수정
|
||||
response = await apiCall(`/tbm/safety-checks/${editingCheckId}`, 'PUT', data);
|
||||
} else {
|
||||
// 추가
|
||||
response = await apiCall('/tbm/safety-checks', 'POST', data);
|
||||
}
|
||||
|
||||
if (response && response.success) {
|
||||
showToast(editingCheckId ? '항목이 수정되었습니다.' : '항목이 추가되었습니다.', 'success');
|
||||
closeModal();
|
||||
await loadAllChecks();
|
||||
renderCurrentTab();
|
||||
} else {
|
||||
showToast(response?.message || '저장에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
showToast('저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 확인
|
||||
*/
|
||||
function confirmDelete(checkId) {
|
||||
const check = allChecks.find(c => c.check_id === checkId);
|
||||
|
||||
if (!check) {
|
||||
showToast('항목을 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`"${check.check_item}" 항목을 삭제하시겠습니까?`)) {
|
||||
deleteCheck(checkId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 항목 삭제
|
||||
*/
|
||||
async function deleteCheck(checkId) {
|
||||
try {
|
||||
const response = await apiCall(`/tbm/safety-checks/${checkId}`, 'DELETE');
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('항목이 삭제되었습니다.', 'success');
|
||||
await loadAllChecks();
|
||||
renderCurrentTab();
|
||||
} else {
|
||||
showToast(response?.message || '삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
function showToast(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;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 9999;
|
||||
animation: fadeInUp 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981; color: white;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444; color: white;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6; color: white;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'fadeOut 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('checkModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// HTML onclick에서 호출할 수 있도록 전역에 노출
|
||||
window.switchTab = switchTab;
|
||||
window.openAddModal = openAddModal;
|
||||
window.openEditModal = openEditModal;
|
||||
window.closeModal = closeModal;
|
||||
window.saveCheck = saveCheck;
|
||||
window.confirmDelete = confirmDelete;
|
||||
window.filterByWeather = filterByWeather;
|
||||
window.filterByWorkType = filterByWorkType;
|
||||
window.filterByTask = filterByTask;
|
||||
window.loadModalTasks = loadModalTasks;
|
||||
window.toggleConditionalFields = toggleConditionalFields;
|
||||
@@ -432,7 +432,7 @@ async function confirmReject() {
|
||||
* 안전교육 진행 페이지로 이동
|
||||
*/
|
||||
function startTraining(requestId) {
|
||||
window.location.href = `/pages/admin/safety-training-conduct.html?request_id=${requestId}`;
|
||||
window.location.href = `/pages/safety/training-conduct.html?request_id=${requestId}`;
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
|
||||
@@ -98,7 +98,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!requestId) {
|
||||
showToast('출입 신청 ID가 없습니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
window.location.href = '/pages/safety/management.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ async function loadRequestInfo() {
|
||||
if (requestData.status !== 'approved') {
|
||||
showToast('이미 처리되었거나 승인되지 않은 신청입니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
window.location.href = '/pages/safety/management.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
@@ -518,7 +518,7 @@ async function completeTraining() {
|
||||
if (successCount === savedSignatures.length) {
|
||||
showToast(`${successCount}명의 안전교육이 완료되었습니다.`, 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
window.location.href = '/pages/safety/management.html';
|
||||
}, 1500);
|
||||
} else if (successCount > 0) {
|
||||
showToast(`${successCount}/${savedSignatures.length}명의 교육만 저장되었습니다.`, 'warning');
|
||||
@@ -540,7 +540,7 @@ function goBack() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
window.location.href = '/pages/safety/management.html';
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
|
||||
455
web-ui/js/tbm.js
455
web-ui/js/tbm.js
@@ -26,6 +26,11 @@ let selectedWorkplaceName = '';
|
||||
let isBulkMode = false; // 일괄 설정 모드인지 여부
|
||||
let bulkSelectedWorkers = new Set(); // 일괄 설정에서 선택된 작업자 인덱스
|
||||
|
||||
// TBM 관리 탭용 변수
|
||||
let loadedDaysCount = 7; // 처음에 로드할 일수
|
||||
let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션
|
||||
let allLoadedSessions = []; // 전체 로드된 세션
|
||||
|
||||
// ==================== 유틸리티 함수 ====================
|
||||
|
||||
/**
|
||||
@@ -87,8 +92,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// 오늘 날짜 설정 (서울 시간대 기준)
|
||||
const today = getTodayKST();
|
||||
document.getElementById('tbmDate').value = today;
|
||||
document.getElementById('sessionDate').value = today;
|
||||
const tbmDateEl = document.getElementById('tbmDate');
|
||||
const sessionDateEl = document.getElementById('sessionDate');
|
||||
if (tbmDateEl) tbmDateEl.value = today;
|
||||
if (sessionDateEl) sessionDateEl.value = today;
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
@@ -100,22 +107,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
const tbmDateInput = document.getElementById('tbmDate');
|
||||
if (tbmDateInput) {
|
||||
tbmDateInput.addEventListener('change', () => {
|
||||
const date = tbmDateInput.value;
|
||||
loadTbmSessionsByDate(date);
|
||||
});
|
||||
}
|
||||
// 날짜 선택기 제거됨 - 날짜별 그룹 뷰 사용
|
||||
}
|
||||
|
||||
// 초기 데이터 로드
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
const userInfo = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
currentUser = userInfo;
|
||||
console.log('👤 로그인 사용자:', currentUser);
|
||||
console.log('👤 로그인 사용자:', currentUser, 'worker_id:', currentUser?.worker_id);
|
||||
|
||||
// 작업자 목록 로드
|
||||
const workersResponse = await window.apiCall('/workers?limit=1000');
|
||||
@@ -202,12 +203,7 @@ function switchTbmTab(tabName) {
|
||||
if (tabName === 'tbm-input') {
|
||||
loadTodayOnlyTbm();
|
||||
} else if (tabName === 'tbm-manage') {
|
||||
const tbmDate = document.getElementById('tbmDate');
|
||||
if (tbmDate && tbmDate.value) {
|
||||
loadTbmSessionsByDate(tbmDate.value);
|
||||
} else {
|
||||
loadTodayTbm();
|
||||
}
|
||||
loadRecentTbmGroupedByDate();
|
||||
}
|
||||
}
|
||||
window.switchTbmTab = switchTbmTab;
|
||||
@@ -268,36 +264,175 @@ function displayTodayTbmSessions() {
|
||||
|
||||
// ==================== TBM 관리 탭 ====================
|
||||
|
||||
// 오늘 TBM 로드 (TBM 관리 탭용)
|
||||
// 오늘 TBM 로드 (TBM 관리 탭용) - 레거시 호환
|
||||
async function loadTodayTbm() {
|
||||
const today = getTodayKST();
|
||||
document.getElementById('tbmDate').value = today;
|
||||
await loadTbmSessionsByDate(today);
|
||||
await loadRecentTbmGroupedByDate();
|
||||
}
|
||||
window.loadTodayTbm = loadTodayTbm;
|
||||
|
||||
// 전체 TBM 로드
|
||||
// 전체 TBM 로드 - 레거시 호환
|
||||
async function loadAllTbm() {
|
||||
try {
|
||||
const response = await window.apiCall('/tbm/sessions');
|
||||
|
||||
if (response && response.success) {
|
||||
allSessions = response.data || [];
|
||||
document.getElementById('tbmDate').value = '';
|
||||
displayTbmSessions();
|
||||
} else {
|
||||
allSessions = [];
|
||||
displayTbmSessions();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 전체 TBM 조회 오류:', error);
|
||||
showToast('전체 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
allSessions = [];
|
||||
displayTbmSessions();
|
||||
}
|
||||
loadedDaysCount = 30; // 30일치 로드
|
||||
await loadRecentTbmGroupedByDate();
|
||||
}
|
||||
window.loadAllTbm = loadAllTbm;
|
||||
|
||||
// ==================== 날짜별 그룹 TBM 로드 (새 기능) ====================
|
||||
|
||||
/**
|
||||
* 사용자가 Admin인지 확인
|
||||
*/
|
||||
function isAdminUser() {
|
||||
if (!currentUser) return false;
|
||||
return currentUser.role === 'Admin' || currentUser.role === 'System Admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 TBM을 날짜별로 그룹화하여 로드
|
||||
*/
|
||||
async function loadRecentTbmGroupedByDate() {
|
||||
try {
|
||||
const today = new Date();
|
||||
const dates = [];
|
||||
|
||||
// 최근 N일의 날짜 생성
|
||||
for (let i = 0; i < loadedDaysCount; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
dates.push(dateStr);
|
||||
}
|
||||
|
||||
// 각 날짜의 TBM 로드
|
||||
dateGroupedSessions = {};
|
||||
allLoadedSessions = [];
|
||||
|
||||
const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`));
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
results.forEach((response, index) => {
|
||||
const date = dates[index];
|
||||
if (response && response.success && response.data && response.data.length > 0) {
|
||||
let sessions = response.data;
|
||||
|
||||
// admin이 아니면 본인이 작성한 TBM만 필터링
|
||||
if (!isAdminUser()) {
|
||||
const userId = currentUser?.user_id;
|
||||
const workerId = currentUser?.worker_id;
|
||||
sessions = sessions.filter(s => {
|
||||
return s.created_by === userId ||
|
||||
s.leader_id === workerId ||
|
||||
s.created_by_name === currentUser?.name;
|
||||
});
|
||||
}
|
||||
|
||||
if (sessions.length > 0) {
|
||||
dateGroupedSessions[date] = sessions;
|
||||
allLoadedSessions = allLoadedSessions.concat(sessions);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 날짜별 그룹 표시
|
||||
displayTbmGroupedByDate();
|
||||
|
||||
// 뷰 모드 표시
|
||||
updateViewModeIndicator();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 날짜별 로드 오류:', error);
|
||||
showToast('TBM을 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
dateGroupedSessions = {};
|
||||
displayTbmGroupedByDate();
|
||||
}
|
||||
}
|
||||
window.loadRecentTbmGroupedByDate = loadRecentTbmGroupedByDate;
|
||||
|
||||
/**
|
||||
* 뷰 모드 표시 업데이트
|
||||
*/
|
||||
function updateViewModeIndicator() {
|
||||
const indicator = document.getElementById('viewModeIndicator');
|
||||
const text = document.getElementById('viewModeText');
|
||||
|
||||
if (indicator && text) {
|
||||
if (isAdminUser()) {
|
||||
indicator.style.display = 'none'; // Admin은 표시 안 함 (전체가 기본)
|
||||
} else {
|
||||
indicator.style.display = 'inline-flex';
|
||||
text.textContent = '내 TBM';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜별 그룹으로 TBM 표시
|
||||
*/
|
||||
function displayTbmGroupedByDate() {
|
||||
const container = document.getElementById('tbmDateGroupsContainer');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const totalSessionsEl = document.getElementById('totalSessions');
|
||||
const completedSessionsEl = document.getElementById('completedSessions');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
// 날짜별로 정렬 (최신순)
|
||||
const sortedDates = Object.keys(dateGroupedSessions).sort((a, b) => new Date(b) - new Date(a));
|
||||
|
||||
if (sortedDates.length === 0 || allLoadedSessions.length === 0) {
|
||||
container.innerHTML = '';
|
||||
if (emptyState) emptyState.style.display = 'flex';
|
||||
if (totalSessionsEl) totalSessionsEl.textContent = '0';
|
||||
if (completedSessionsEl) completedSessionsEl.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
if (emptyState) emptyState.style.display = 'none';
|
||||
|
||||
// 통계 업데이트
|
||||
const completedCount = allLoadedSessions.filter(s => s.status === 'completed').length;
|
||||
if (totalSessionsEl) totalSessionsEl.textContent = allLoadedSessions.length;
|
||||
if (completedSessionsEl) completedSessionsEl.textContent = completedCount;
|
||||
|
||||
// 날짜별 그룹 HTML 생성
|
||||
const today = getTodayKST();
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
container.innerHTML = sortedDates.map(date => {
|
||||
const sessions = dateGroupedSessions[date];
|
||||
const dateObj = new Date(date + 'T00:00:00');
|
||||
const dayName = dayNames[dateObj.getDay()];
|
||||
const isToday = date === today;
|
||||
|
||||
// 날짜 포맷팅 (YYYY-MM-DD → MM월 DD일)
|
||||
const [year, month, day] = date.split('-');
|
||||
const displayDate = `${parseInt(month)}월 ${parseInt(day)}일`;
|
||||
|
||||
return `
|
||||
<div class="date-group">
|
||||
<div class="date-group-header ${isToday ? 'today' : ''}">
|
||||
<span class="date-group-date">${displayDate}</span>
|
||||
<span class="date-group-day">${dayName}요일${isToday ? ' (오늘)' : ''}</span>
|
||||
<span class="date-group-count">${sessions.length}건</span>
|
||||
</div>
|
||||
<div class="date-group-grid">
|
||||
${sessions.map(session => createSessionCard(session)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 더 많은 날짜 로드
|
||||
*/
|
||||
async function loadMoreTbmDays() {
|
||||
loadedDaysCount += 7; // 7일씩 추가
|
||||
await loadRecentTbmGroupedByDate();
|
||||
showToast(`최근 ${loadedDaysCount}일의 TBM을 로드했습니다.`, 'success');
|
||||
}
|
||||
window.loadMoreTbmDays = loadMoreTbmDays;
|
||||
|
||||
// 특정 날짜의 TBM 세션 목록 로드
|
||||
async function loadTbmSessionsByDate(date) {
|
||||
try {
|
||||
@@ -318,28 +453,22 @@ async function loadTbmSessionsByDate(date) {
|
||||
}
|
||||
}
|
||||
|
||||
// TBM 세션 목록 표시 (관리 탭용)
|
||||
// TBM 세션 목록 표시 (관리 탭용) - 레거시 호환 (날짜별 그룹 뷰 사용)
|
||||
function displayTbmSessions() {
|
||||
const grid = document.getElementById('tbmSessionsGrid');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const totalSessionsEl = document.getElementById('totalSessions');
|
||||
const completedSessionsEl = document.getElementById('completedSessions');
|
||||
|
||||
if (allSessions.length === 0) {
|
||||
grid.innerHTML = '';
|
||||
emptyState.style.display = 'flex';
|
||||
totalSessionsEl.textContent = '0';
|
||||
completedSessionsEl.textContent = '0';
|
||||
return;
|
||||
// 새 날짜별 그룹 뷰로 리다이렉트
|
||||
if (allSessions.length > 0) {
|
||||
// allSessions를 날짜별로 그룹화
|
||||
dateGroupedSessions = {};
|
||||
allSessions.forEach(session => {
|
||||
const date = formatDate(session.session_date);
|
||||
if (!dateGroupedSessions[date]) {
|
||||
dateGroupedSessions[date] = [];
|
||||
}
|
||||
dateGroupedSessions[date].push(session);
|
||||
});
|
||||
allLoadedSessions = allSessions;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
const completedCount = allSessions.filter(s => s.status === 'completed').length;
|
||||
totalSessionsEl.textContent = allSessions.length;
|
||||
completedSessionsEl.textContent = completedCount;
|
||||
|
||||
grid.innerHTML = allSessions.map(session => createSessionCard(session)).join('');
|
||||
displayTbmGroupedByDate();
|
||||
}
|
||||
|
||||
// TBM 세션 카드 생성 (공통)
|
||||
@@ -432,12 +561,12 @@ function openNewTbmModal() {
|
||||
if (currentUser && currentUser.worker_id) {
|
||||
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
|
||||
if (worker) {
|
||||
document.getElementById('leaderName').value = `${worker.worker_name} (${worker.job_type || ''})`;
|
||||
document.getElementById('leaderName').value = worker.worker_name;
|
||||
document.getElementById('leaderId').value = worker.worker_id;
|
||||
}
|
||||
} else if (currentUser && currentUser.name) {
|
||||
// 관리자: 관리자로 표시
|
||||
document.getElementById('leaderName').value = `${currentUser.name} (관리자)`;
|
||||
// 관리자: 이름만 표시
|
||||
document.getElementById('leaderName').value = currentUser.name;
|
||||
document.getElementById('leaderId').value = '';
|
||||
}
|
||||
|
||||
@@ -459,7 +588,8 @@ function populateLeaderSelect() {
|
||||
// 작업자와 연결된 경우: 자동으로 선택하고 비활성화
|
||||
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
|
||||
if (worker) {
|
||||
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name} (${worker.job_type || ''})</option>`;
|
||||
const jobTypeText = worker.job_type ? ` (${worker.job_type})` : '';
|
||||
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name}${jobTypeText}</option>`;
|
||||
leaderSelect.disabled = true;
|
||||
console.log('✅ 입력자 자동 설정:', worker.worker_name);
|
||||
} else {
|
||||
@@ -474,9 +604,10 @@ function populateLeaderSelect() {
|
||||
);
|
||||
|
||||
leaderSelect.innerHTML = '<option value="">입력자 선택...</option>' +
|
||||
leaders.map(w => `
|
||||
<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</option>
|
||||
`).join('');
|
||||
leaders.map(w => {
|
||||
const jobTypeText = w.job_type ? ` (${w.job_type})` : '';
|
||||
return `<option value="${w.worker_id}">${w.worker_name}${jobTypeText}</option>`;
|
||||
}).join('');
|
||||
leaderSelect.disabled = false;
|
||||
console.log('✅ 관리자: 입력자 선택 가능');
|
||||
}
|
||||
@@ -1856,65 +1987,92 @@ async function saveTeamComposition() {
|
||||
}
|
||||
window.saveTeamComposition = saveTeamComposition;
|
||||
|
||||
// 안전 체크 모달 열기
|
||||
// 안전 체크 모달 열기 (기본 + 날씨별 + 작업별)
|
||||
async function openSafetyCheckModal(sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
|
||||
// 기존 안전 체크 기록 로드
|
||||
try {
|
||||
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`);
|
||||
const existingRecords = response && response.success ? response.data : [];
|
||||
// 필터링된 체크리스트 조회 (기본 + 날씨 + 작업별)
|
||||
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety-checks/filtered`);
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const grouped = {};
|
||||
allSafetyChecks.forEach(check => {
|
||||
if (!grouped[check.check_category]) {
|
||||
grouped[check.check_category] = [];
|
||||
}
|
||||
if (!response || !response.success) {
|
||||
throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.');
|
||||
}
|
||||
|
||||
const existingRecord = existingRecords.find(r => r.check_id === check.check_id);
|
||||
grouped[check.check_category].push({
|
||||
...check,
|
||||
is_checked: existingRecord ? existingRecord.is_checked : false,
|
||||
notes: existingRecord ? existingRecord.notes : ''
|
||||
});
|
||||
});
|
||||
const { basic, weather, task, weatherInfo } = response.data;
|
||||
|
||||
const categoryNames = {
|
||||
'PPE': '개인 보호 장비',
|
||||
'EQUIPMENT': '장비 점검',
|
||||
'ENVIRONMENT': '작업 환경',
|
||||
'EMERGENCY': '비상 대응'
|
||||
'EMERGENCY': '비상 대응',
|
||||
'WEATHER': '날씨',
|
||||
'TASK': '작업'
|
||||
};
|
||||
|
||||
const weatherIcons = {
|
||||
clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥',
|
||||
cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷'
|
||||
};
|
||||
|
||||
const container = document.getElementById('safetyChecklistContainer');
|
||||
container.innerHTML = Object.keys(grouped).map(category => `
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 600; font-size: 0.9375rem; color: #374151; padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; margin-bottom: 0.5rem;">
|
||||
${categoryNames[category] || category}
|
||||
</div>
|
||||
${grouped[category].map(check => `
|
||||
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6;">
|
||||
<label style="display: flex; align-items: start; gap: 0.75rem; cursor: pointer;">
|
||||
<input type="checkbox"
|
||||
class="safety-check"
|
||||
data-check-id="${check.check_id}"
|
||||
${check.is_checked ? 'checked' : ''}
|
||||
${check.is_required ? 'required' : ''}
|
||||
style="width: 18px; height: 18px; margin-top: 0.125rem; cursor: pointer;">
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; color: #111827;">
|
||||
${check.check_item}
|
||||
${check.is_required ? '<span style="color: #ef4444;">*</span>' : ''}
|
||||
</div>
|
||||
${check.description ? `<div style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">${check.description}</div>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
let html = '';
|
||||
|
||||
// 1. 기본 사항 섹션
|
||||
if (basic && basic.length > 0) {
|
||||
const basicGrouped = groupChecksByCategory(basic);
|
||||
html += `
|
||||
<div class="safety-section" style="margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span>📋</span> 기본 안전 사항 (${basic.length}개)
|
||||
</div>
|
||||
${renderCategoryGroups(basicGrouped, categoryNames)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 2. 날씨별 섹션
|
||||
if (weather && weather.length > 0) {
|
||||
const weatherConditions = weatherInfo?.weather_conditions || [];
|
||||
const conditionNames = weatherConditions.map(c => {
|
||||
const icon = weatherIcons[c] || '🌤️';
|
||||
return `${icon} ${getWeatherConditionName(c)}`;
|
||||
}).join(', ') || '맑음';
|
||||
|
||||
html += `
|
||||
<div class="safety-section" style="margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #f59e0b, #d97706); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span>🌤️</span> 오늘 날씨 관련 (${conditionNames}) - ${weather.length}개
|
||||
</div>
|
||||
${renderCheckItems(weather)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 3. 작업별 섹션
|
||||
if (task && task.length > 0) {
|
||||
const taskGrouped = groupChecksByTask(task);
|
||||
html += `
|
||||
<div class="safety-section" style="margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #10b981, #059669); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span>🔧</span> 작업별 안전 사항 - ${task.length}개
|
||||
</div>
|
||||
${renderTaskGroups(taskGrouped)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 체크리스트가 없는 경우
|
||||
if ((!basic || basic.length === 0) && (!weather || weather.length === 0) && (!task || task.length === 0)) {
|
||||
html = `
|
||||
<div style="text-align: center; padding: 2rem; color: #6b7280;">
|
||||
<div style="font-size: 2rem; margin-bottom: 0.5rem;">📋</div>
|
||||
<p>등록된 안전 체크 항목이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
document.getElementById('safetyModal').style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
@@ -1925,6 +2083,83 @@ async function openSafetyCheckModal(sessionId) {
|
||||
}
|
||||
window.openSafetyCheckModal = openSafetyCheckModal;
|
||||
|
||||
// 카테고리별 그룹화
|
||||
function groupChecksByCategory(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const category = check.check_category || 'OTHER';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// 작업별 그룹화
|
||||
function groupChecksByTask(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const taskId = check.task_id || 0;
|
||||
const taskName = check.task_name || '기타 작업';
|
||||
if (!acc[taskId]) acc[taskId] = { name: taskName, items: [] };
|
||||
acc[taskId].items.push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// 날씨 조건명 반환
|
||||
function getWeatherConditionName(code) {
|
||||
const names = {
|
||||
clear: '맑음', rain: '비', snow: '눈', heat: '폭염',
|
||||
cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지'
|
||||
};
|
||||
return names[code] || code;
|
||||
}
|
||||
|
||||
// 카테고리 그룹 렌더링
|
||||
function renderCategoryGroups(grouped, categoryNames) {
|
||||
return Object.keys(grouped).map(category => `
|
||||
<div style="margin-bottom: 1rem; background: white; border-radius: 0.5rem; border: 1px solid #e5e7eb; overflow: hidden;">
|
||||
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; padding: 0.625rem 0.875rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb;">
|
||||
${categoryNames[category] || category}
|
||||
</div>
|
||||
${renderCheckItems(grouped[category])}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 작업 그룹 렌더링
|
||||
function renderTaskGroups(grouped) {
|
||||
return Object.values(grouped).map(group => `
|
||||
<div style="margin-bottom: 1rem; background: white; border-radius: 0.5rem; border: 1px solid #e5e7eb; overflow: hidden;">
|
||||
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; padding: 0.625rem 0.875rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb;">
|
||||
📋 ${group.name}
|
||||
</div>
|
||||
${renderCheckItems(group.items)}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 체크 항목 렌더링
|
||||
function renderCheckItems(items) {
|
||||
return items.map(check => `
|
||||
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6;">
|
||||
<label style="display: flex; align-items: start; gap: 0.75rem; cursor: pointer;">
|
||||
<input type="checkbox"
|
||||
class="safety-check"
|
||||
data-check-id="${check.check_id}"
|
||||
${check.is_checked ? 'checked' : ''}
|
||||
${check.is_required ? 'required' : ''}
|
||||
style="width: 18px; height: 18px; margin-top: 0.125rem; cursor: pointer;">
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; color: #111827;">
|
||||
${check.check_item}
|
||||
${check.is_required ? '<span style="color: #ef4444;">*</span>' : ''}
|
||||
</div>
|
||||
${check.description ? `<div style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">${check.description}</div>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 안전 체크 모달 닫기
|
||||
function closeSafetyModal() {
|
||||
document.getElementById('safetyModal').style.display = 'none';
|
||||
|
||||
221
web-ui/js/work-issue-list.js
Normal file
221
web-ui/js/work-issue-list.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 문제 신고 목록 페이지 JavaScript
|
||||
*/
|
||||
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
|
||||
// 상태 한글 변환
|
||||
const STATUS_LABELS = {
|
||||
reported: '신고',
|
||||
received: '접수',
|
||||
in_progress: '처리중',
|
||||
completed: '완료',
|
||||
closed: '종료'
|
||||
};
|
||||
|
||||
// 유형 한글 변환
|
||||
const TYPE_LABELS = {
|
||||
nonconformity: '부적합',
|
||||
safety: '안전'
|
||||
};
|
||||
|
||||
// DOM 요소
|
||||
let issueList;
|
||||
let filterStatus, filterType, filterStartDate, filterEndDate;
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
issueList = document.getElementById('issueList');
|
||||
filterStatus = document.getElementById('filterStatus');
|
||||
filterType = document.getElementById('filterType');
|
||||
filterStartDate = document.getElementById('filterStartDate');
|
||||
filterEndDate = document.getElementById('filterEndDate');
|
||||
|
||||
// 필터 이벤트 리스너
|
||||
filterStatus.addEventListener('change', loadIssues);
|
||||
filterType.addEventListener('change', loadIssues);
|
||||
filterStartDate.addEventListener('change', loadIssues);
|
||||
filterEndDate.addEventListener('change', loadIssues);
|
||||
|
||||
// 데이터 로드
|
||||
await Promise.all([loadStats(), loadIssues()]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 통계 로드
|
||||
*/
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/stats/summary`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 권한이 없는 경우 (일반 사용자)
|
||||
document.getElementById('statsGrid').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
document.getElementById('statReported').textContent = data.data.reported || 0;
|
||||
document.getElementById('statReceived').textContent = data.data.received || 0;
|
||||
document.getElementById('statProgress').textContent = data.data.in_progress || 0;
|
||||
document.getElementById('statCompleted').textContent = data.data.completed || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('통계 로드 실패:', error);
|
||||
document.getElementById('statsGrid').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고 목록 로드
|
||||
*/
|
||||
async function loadIssues() {
|
||||
try {
|
||||
// 필터 파라미터 구성
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filterStatus.value) params.append('status', filterStatus.value);
|
||||
if (filterType.value) params.append('category_type', filterType.value);
|
||||
if (filterStartDate.value) params.append('start_date', filterStartDate.value);
|
||||
if (filterEndDate.value) params.append('end_date', filterEndDate.value);
|
||||
|
||||
const response = await fetch(`${API_BASE}/work-issues?${params.toString()}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('목록 조회 실패');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
renderIssues(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('신고 목록 로드 실패:', error);
|
||||
issueList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">목록을 불러올 수 없습니다</div>
|
||||
<p>잠시 후 다시 시도해주세요.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고 목록 렌더링
|
||||
*/
|
||||
function renderIssues(issues) {
|
||||
if (issues.length === 0) {
|
||||
issueList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">등록된 신고가 없습니다</div>
|
||||
<p>새로운 문제를 신고하려면 '새 신고' 버튼을 클릭하세요.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
|
||||
issueList.innerHTML = issues.map(issue => {
|
||||
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// 위치 정보
|
||||
let location = issue.custom_location || '';
|
||||
if (issue.factory_name) {
|
||||
location = issue.factory_name;
|
||||
if (issue.workplace_name) {
|
||||
location += ` - ${issue.workplace_name}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 신고 제목 (항목명 또는 카테고리명)
|
||||
const title = issue.issue_item_name || issue.issue_category_name || '신고';
|
||||
|
||||
// 사진 목록
|
||||
const photos = [
|
||||
issue.photo_path1,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
].filter(Boolean);
|
||||
|
||||
return `
|
||||
<div class="issue-card" onclick="viewIssue(${issue.report_id})">
|
||||
<div class="issue-header">
|
||||
<span class="issue-id">#${issue.report_id}</span>
|
||||
<span class="issue-status ${issue.status}">${STATUS_LABELS[issue.status] || issue.status}</span>
|
||||
</div>
|
||||
|
||||
<div class="issue-title">
|
||||
<span class="issue-type-badge ${issue.category_type}">${TYPE_LABELS[issue.category_type] || ''}</span>
|
||||
${title}
|
||||
</div>
|
||||
|
||||
<div class="issue-meta">
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
${issue.reporter_full_name || issue.reporter_name}
|
||||
</span>
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
${reportDate}
|
||||
</span>
|
||||
${location ? `
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
${location}
|
||||
</span>
|
||||
` : ''}
|
||||
${issue.assigned_full_name ? `
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
담당: ${issue.assigned_full_name}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${photos.length > 0 ? `
|
||||
<div class="issue-photos">
|
||||
${photos.slice(0, 3).map(p => `
|
||||
<img src="${baseUrl}${p}" alt="신고 사진" loading="lazy">
|
||||
`).join('')}
|
||||
${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고 상세 보기
|
||||
*/
|
||||
function viewIssue(reportId) {
|
||||
window.location.href = `/pages/safety/issue-detail.html?id=${reportId}`;
|
||||
}
|
||||
740
web-ui/js/work-issue-report.js
Normal file
740
web-ui/js/work-issue-report.js
Normal file
@@ -0,0 +1,740 @@
|
||||
/**
|
||||
* 문제 신고 등록 페이지 JavaScript
|
||||
*/
|
||||
|
||||
// API 설정
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
|
||||
// 상태 변수
|
||||
let selectedFactoryId = null;
|
||||
let selectedWorkplaceId = null;
|
||||
let selectedWorkplaceName = null;
|
||||
let selectedType = null; // 'nonconformity' | 'safety'
|
||||
let selectedCategoryId = null;
|
||||
let selectedCategoryName = null;
|
||||
let selectedItemId = null;
|
||||
let selectedTbmSessionId = null;
|
||||
let selectedVisitRequestId = null;
|
||||
let photos = [null, null, null, null, null];
|
||||
|
||||
// 지도 관련 변수
|
||||
let canvas, ctx, canvasImage;
|
||||
let mapRegions = [];
|
||||
let todayWorkers = [];
|
||||
let todayVisitors = [];
|
||||
|
||||
// DOM 요소
|
||||
let factorySelect, issueMapCanvas;
|
||||
let photoInput, currentPhotoIndex;
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
factorySelect = document.getElementById('factorySelect');
|
||||
issueMapCanvas = document.getElementById('issueMapCanvas');
|
||||
photoInput = document.getElementById('photoInput');
|
||||
|
||||
canvas = issueMapCanvas;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
// 공장 목록 로드
|
||||
await loadFactories();
|
||||
});
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// 공장 선택
|
||||
factorySelect.addEventListener('change', onFactoryChange);
|
||||
|
||||
// 지도 클릭
|
||||
canvas.addEventListener('click', onMapClick);
|
||||
|
||||
// 기타 위치 토글
|
||||
document.getElementById('useCustomLocation').addEventListener('change', (e) => {
|
||||
const customInput = document.getElementById('customLocationInput');
|
||||
customInput.classList.toggle('visible', e.target.checked);
|
||||
|
||||
if (e.target.checked) {
|
||||
// 지도 선택 초기화
|
||||
selectedWorkplaceId = null;
|
||||
selectedWorkplaceName = null;
|
||||
selectedTbmSessionId = null;
|
||||
selectedVisitRequestId = null;
|
||||
updateLocationInfo();
|
||||
}
|
||||
});
|
||||
|
||||
// 유형 버튼 클릭
|
||||
document.querySelectorAll('.type-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => onTypeSelect(btn.dataset.type));
|
||||
});
|
||||
|
||||
// 사진 슬롯 클릭
|
||||
document.querySelectorAll('.photo-slot').forEach(slot => {
|
||||
slot.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('remove-btn')) return;
|
||||
currentPhotoIndex = parseInt(slot.dataset.index);
|
||||
photoInput.click();
|
||||
});
|
||||
});
|
||||
|
||||
// 사진 삭제 버튼
|
||||
document.querySelectorAll('.photo-slot .remove-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const slot = btn.closest('.photo-slot');
|
||||
const index = parseInt(slot.dataset.index);
|
||||
removePhoto(index);
|
||||
});
|
||||
});
|
||||
|
||||
// 사진 선택
|
||||
photoInput.addEventListener('change', onPhotoSelect);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공장 목록 로드
|
||||
*/
|
||||
async function loadFactories() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/workplaces/categories/active/list`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('공장 목록 조회 실패');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
data.data.forEach(factory => {
|
||||
const option = document.createElement('option');
|
||||
option.value = factory.category_id;
|
||||
option.textContent = factory.category_name;
|
||||
factorySelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 첫 번째 공장 자동 선택
|
||||
if (data.data.length > 0) {
|
||||
factorySelect.value = data.data[0].category_id;
|
||||
onFactoryChange();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('공장 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공장 변경 시
|
||||
*/
|
||||
async function onFactoryChange() {
|
||||
selectedFactoryId = factorySelect.value;
|
||||
if (!selectedFactoryId) return;
|
||||
|
||||
// 위치 선택 초기화
|
||||
selectedWorkplaceId = null;
|
||||
selectedWorkplaceName = null;
|
||||
selectedTbmSessionId = null;
|
||||
selectedVisitRequestId = null;
|
||||
updateLocationInfo();
|
||||
|
||||
// 지도 데이터 로드
|
||||
await Promise.all([
|
||||
loadMapImage(),
|
||||
loadMapRegions(),
|
||||
loadTodayData()
|
||||
]);
|
||||
|
||||
renderMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치도 이미지 로드
|
||||
*/
|
||||
async function loadMapImage() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/workplaces/categories`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
const selectedCategory = data.data.find(c => c.category_id == selectedFactoryId);
|
||||
if (selectedCategory && selectedCategory.layout_image) {
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
|
||||
? selectedCategory.layout_image
|
||||
: `${baseUrl}${selectedCategory.layout_image}`;
|
||||
|
||||
canvasImage = new Image();
|
||||
canvasImage.onload = () => renderMap();
|
||||
canvasImage.src = fullImageUrl;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('배치도 이미지 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 영역 로드
|
||||
*/
|
||||
async function loadMapRegions() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/workplaces/categories/${selectedFactoryId}/map-regions`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
mapRegions = data.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('지도 영역 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 TBM/출입신청 데이터 로드
|
||||
*/
|
||||
async function loadTodayData() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
// TBM 세션 로드
|
||||
const tbmResponse = await fetch(`${API_BASE}/tbm/sessions/date/${today}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (tbmResponse.ok) {
|
||||
const tbmData = await tbmResponse.json();
|
||||
todayWorkers = tbmData.data || [];
|
||||
}
|
||||
|
||||
// 출입 신청 로드
|
||||
const visitResponse = await fetch(`${API_BASE}/workplace-visits/requests`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (visitResponse.ok) {
|
||||
const visitData = await visitResponse.json();
|
||||
todayVisitors = (visitData.data || []).filter(v =>
|
||||
v.visit_date === today &&
|
||||
(v.status === 'approved' || v.status === 'training_completed')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('오늘 데이터 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 렌더링
|
||||
*/
|
||||
function renderMap() {
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
// 캔버스 크기 설정
|
||||
const container = canvas.parentElement;
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = 400;
|
||||
|
||||
// 배경 그리기
|
||||
ctx.fillStyle = '#f3f4f6';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 배치도 이미지
|
||||
if (canvasImage && canvasImage.complete) {
|
||||
const scale = Math.min(canvas.width / canvasImage.width, canvas.height / canvasImage.height);
|
||||
const x = (canvas.width - canvasImage.width * scale) / 2;
|
||||
const y = (canvas.height - canvasImage.height * scale) / 2;
|
||||
ctx.drawImage(canvasImage, x, y, canvasImage.width * scale, canvasImage.height * scale);
|
||||
}
|
||||
|
||||
// 작업장 영역 그리기
|
||||
mapRegions.forEach(region => {
|
||||
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
|
||||
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
|
||||
|
||||
const workerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
|
||||
const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
|
||||
|
||||
drawWorkplaceRegion(region, workerCount, visitorCount);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 영역 그리기
|
||||
*/
|
||||
function drawWorkplaceRegion(region, workerCount, visitorCount) {
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
|
||||
// 선택된 작업장 하이라이트
|
||||
const isSelected = region.workplace_id === selectedWorkplaceId;
|
||||
|
||||
// 색상 결정
|
||||
let fillColor, strokeColor;
|
||||
if (isSelected) {
|
||||
fillColor = 'rgba(34, 197, 94, 0.3)'; // 초록색
|
||||
strokeColor = 'rgb(34, 197, 94)';
|
||||
} else if (workerCount > 0 && visitorCount > 0) {
|
||||
fillColor = 'rgba(34, 197, 94, 0.2)'; // 초록색 (작업+방문)
|
||||
strokeColor = 'rgb(34, 197, 94)';
|
||||
} else if (workerCount > 0) {
|
||||
fillColor = 'rgba(59, 130, 246, 0.2)'; // 파란색 (작업만)
|
||||
strokeColor = 'rgb(59, 130, 246)';
|
||||
} else if (visitorCount > 0) {
|
||||
fillColor = 'rgba(168, 85, 247, 0.2)'; // 보라색 (방문만)
|
||||
strokeColor = 'rgb(168, 85, 247)';
|
||||
} else {
|
||||
fillColor = 'rgba(156, 163, 175, 0.2)'; // 회색 (없음)
|
||||
strokeColor = 'rgb(156, 163, 175)';
|
||||
}
|
||||
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = isSelected ? 3 : 2;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.rect(x1, y1, width, height);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// 작업장명 표시
|
||||
const centerX = x1 + width / 2;
|
||||
const centerY = y1 + height / 2;
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(region.workplace_name, centerX, centerY);
|
||||
|
||||
// 인원수 표시
|
||||
const total = workerCount + visitorCount;
|
||||
if (total > 0) {
|
||||
ctx.fillStyle = strokeColor;
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText(`(${total}명)`, centerX, centerY + 16);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 클릭 처리
|
||||
*/
|
||||
function onMapClick(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// 클릭된 영역 찾기
|
||||
for (const region of mapRegions) {
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
|
||||
selectWorkplace(region);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 선택
|
||||
*/
|
||||
function selectWorkplace(region) {
|
||||
// 기타 위치 체크박스 해제
|
||||
document.getElementById('useCustomLocation').checked = false;
|
||||
document.getElementById('customLocationInput').classList.remove('visible');
|
||||
|
||||
selectedWorkplaceId = region.workplace_id;
|
||||
selectedWorkplaceName = region.workplace_name;
|
||||
|
||||
// 해당 작업장의 TBM/출입신청 확인
|
||||
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
|
||||
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
|
||||
|
||||
if (workers.length > 0 || visitors.length > 0) {
|
||||
// 작업 선택 모달 표시
|
||||
showWorkSelectionModal(workers, visitors);
|
||||
} else {
|
||||
selectedTbmSessionId = null;
|
||||
selectedVisitRequestId = null;
|
||||
}
|
||||
|
||||
updateLocationInfo();
|
||||
renderMap();
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 선택 모달 표시
|
||||
*/
|
||||
function showWorkSelectionModal(workers, visitors) {
|
||||
const modal = document.getElementById('workSelectionModal');
|
||||
const optionsList = document.getElementById('workOptionsList');
|
||||
|
||||
optionsList.innerHTML = '';
|
||||
|
||||
// TBM 작업 옵션
|
||||
workers.forEach(w => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'work-option';
|
||||
option.innerHTML = `
|
||||
<div class="work-option-title">TBM: ${w.task_name || '작업'}</div>
|
||||
<div class="work-option-desc">${w.project_name || ''} - ${w.member_count || 0}명</div>
|
||||
`;
|
||||
option.onclick = () => {
|
||||
selectedTbmSessionId = w.session_id;
|
||||
selectedVisitRequestId = null;
|
||||
closeWorkModal();
|
||||
updateLocationInfo();
|
||||
};
|
||||
optionsList.appendChild(option);
|
||||
});
|
||||
|
||||
// 출입신청 옵션
|
||||
visitors.forEach(v => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'work-option';
|
||||
option.innerHTML = `
|
||||
<div class="work-option-title">출입: ${v.visitor_company}</div>
|
||||
<div class="work-option-desc">${v.purpose_name || '방문'} - ${v.visitor_count || 0}명</div>
|
||||
`;
|
||||
option.onclick = () => {
|
||||
selectedVisitRequestId = v.request_id;
|
||||
selectedTbmSessionId = null;
|
||||
closeWorkModal();
|
||||
updateLocationInfo();
|
||||
};
|
||||
optionsList.appendChild(option);
|
||||
});
|
||||
|
||||
modal.classList.add('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 선택 모달 닫기
|
||||
*/
|
||||
function closeWorkModal() {
|
||||
document.getElementById('workSelectionModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 위치 정보 업데이트
|
||||
*/
|
||||
function updateLocationInfo() {
|
||||
const infoBox = document.getElementById('selectedLocationInfo');
|
||||
const customLocation = document.getElementById('customLocation').value;
|
||||
const useCustom = document.getElementById('useCustomLocation').checked;
|
||||
|
||||
if (useCustom && customLocation) {
|
||||
infoBox.classList.remove('empty');
|
||||
infoBox.innerHTML = `<strong>선택된 위치:</strong> ${customLocation}`;
|
||||
} else if (selectedWorkplaceName) {
|
||||
infoBox.classList.remove('empty');
|
||||
let html = `<strong>선택된 위치:</strong> ${selectedWorkplaceName}`;
|
||||
|
||||
if (selectedTbmSessionId) {
|
||||
const worker = todayWorkers.find(w => w.session_id === selectedTbmSessionId);
|
||||
if (worker) {
|
||||
html += `<br><span style="color: var(--primary-600);">연결 작업: ${worker.task_name} (TBM)</span>`;
|
||||
}
|
||||
} else if (selectedVisitRequestId) {
|
||||
const visitor = todayVisitors.find(v => v.request_id === selectedVisitRequestId);
|
||||
if (visitor) {
|
||||
html += `<br><span style="color: var(--primary-600);">연결 작업: ${visitor.visitor_company} (출입)</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
infoBox.innerHTML = html;
|
||||
} else {
|
||||
infoBox.classList.add('empty');
|
||||
infoBox.textContent = '지도에서 작업장을 클릭하여 위치를 선택하세요';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형 선택
|
||||
*/
|
||||
function onTypeSelect(type) {
|
||||
selectedType = type;
|
||||
selectedCategoryId = null;
|
||||
selectedCategoryName = null;
|
||||
selectedItemId = null;
|
||||
|
||||
// 버튼 상태 업데이트
|
||||
document.querySelectorAll('.type-btn').forEach(btn => {
|
||||
btn.classList.toggle('selected', btn.dataset.type === type);
|
||||
});
|
||||
|
||||
// 카테고리 로드
|
||||
loadCategories(type);
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 로드
|
||||
*/
|
||||
async function loadCategories(type) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/categories/type/${type}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('카테고리 조회 실패');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
renderCategories(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 렌더링
|
||||
*/
|
||||
function renderCategories(categories) {
|
||||
const container = document.getElementById('categoryContainer');
|
||||
const grid = document.getElementById('categoryGrid');
|
||||
|
||||
grid.innerHTML = '';
|
||||
|
||||
categories.forEach(cat => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'category-btn';
|
||||
btn.textContent = cat.category_name;
|
||||
btn.onclick = () => onCategorySelect(cat);
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
container.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 선택
|
||||
*/
|
||||
function onCategorySelect(category) {
|
||||
selectedCategoryId = category.category_id;
|
||||
selectedCategoryName = category.category_name;
|
||||
selectedItemId = null;
|
||||
|
||||
// 버튼 상태 업데이트
|
||||
document.querySelectorAll('.category-btn').forEach(btn => {
|
||||
btn.classList.toggle('selected', btn.textContent === category.category_name);
|
||||
});
|
||||
|
||||
// 항목 로드
|
||||
loadItems(category.category_id);
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 로드
|
||||
*/
|
||||
async function loadItems(categoryId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/items/category/${categoryId}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('항목 조회 실패');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
renderItems(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('항목 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 렌더링
|
||||
*/
|
||||
function renderItems(items) {
|
||||
const grid = document.getElementById('itemGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (items.length === 0) {
|
||||
grid.innerHTML = '<p style="color: var(--gray-400);">등록된 항목이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'item-btn';
|
||||
btn.textContent = item.item_name;
|
||||
btn.dataset.severity = item.severity;
|
||||
btn.onclick = () => onItemSelect(item, btn);
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 선택
|
||||
*/
|
||||
function onItemSelect(item, btn) {
|
||||
// 단일 선택 (기존 선택 해제)
|
||||
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected'));
|
||||
btn.classList.add('selected');
|
||||
|
||||
selectedItemId = item.item_id;
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사진 선택
|
||||
*/
|
||||
function onPhotoSelect(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
photos[currentPhotoIndex] = event.target.result;
|
||||
updatePhotoSlot(currentPhotoIndex);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// 입력 초기화
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 사진 슬롯 업데이트
|
||||
*/
|
||||
function updatePhotoSlot(index) {
|
||||
const slot = document.querySelector(`.photo-slot[data-index="${index}"]`);
|
||||
|
||||
if (photos[index]) {
|
||||
slot.classList.add('has-photo');
|
||||
let img = slot.querySelector('img');
|
||||
if (!img) {
|
||||
img = document.createElement('img');
|
||||
slot.insertBefore(img, slot.firstChild);
|
||||
}
|
||||
img.src = photos[index];
|
||||
} else {
|
||||
slot.classList.remove('has-photo');
|
||||
const img = slot.querySelector('img');
|
||||
if (img) img.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사진 삭제
|
||||
*/
|
||||
function removePhoto(index) {
|
||||
photos[index] = null;
|
||||
updatePhotoSlot(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단계 상태 업데이트
|
||||
*/
|
||||
function updateStepStatus() {
|
||||
const steps = document.querySelectorAll('.step');
|
||||
const customLocation = document.getElementById('customLocation').value;
|
||||
const useCustom = document.getElementById('useCustomLocation').checked;
|
||||
|
||||
// Step 1: 위치
|
||||
const step1Complete = (useCustom && customLocation) || selectedWorkplaceId;
|
||||
steps[0].classList.toggle('completed', step1Complete);
|
||||
steps[1].classList.toggle('active', step1Complete);
|
||||
|
||||
// Step 2: 유형
|
||||
const step2Complete = selectedType && selectedCategoryId;
|
||||
steps[1].classList.toggle('completed', step2Complete);
|
||||
steps[2].classList.toggle('active', step2Complete);
|
||||
|
||||
// Step 3: 항목
|
||||
const step3Complete = selectedItemId;
|
||||
steps[2].classList.toggle('completed', step3Complete);
|
||||
steps[3].classList.toggle('active', step3Complete);
|
||||
|
||||
// 제출 버튼 활성화
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const hasPhoto = photos.some(p => p !== null);
|
||||
submitBtn.disabled = !(step1Complete && step2Complete && hasPhoto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고 제출
|
||||
*/
|
||||
async function submitReport() {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '제출 중...';
|
||||
|
||||
try {
|
||||
const useCustom = document.getElementById('useCustomLocation').checked;
|
||||
const customLocation = document.getElementById('customLocation').value;
|
||||
const additionalDescription = document.getElementById('additionalDescription').value;
|
||||
|
||||
const requestBody = {
|
||||
factory_category_id: useCustom ? null : selectedFactoryId,
|
||||
workplace_id: useCustom ? null : selectedWorkplaceId,
|
||||
custom_location: useCustom ? customLocation : null,
|
||||
tbm_session_id: selectedTbmSessionId,
|
||||
visit_request_id: selectedVisitRequestId,
|
||||
issue_category_id: selectedCategoryId,
|
||||
issue_item_id: selectedItemId,
|
||||
additional_description: additionalDescription || null,
|
||||
photos: photos.filter(p => p !== null)
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE}/work-issues`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('문제 신고가 등록되었습니다.');
|
||||
window.location.href = '/pages/safety/issue-list.html';
|
||||
} else {
|
||||
throw new Error(data.error || '신고 등록 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('신고 제출 실패:', error);
|
||||
alert('신고 등록에 실패했습니다: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '신고 제출';
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 위치 입력 시 위치 정보 업데이트
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const customLocationInput = document.getElementById('customLocation');
|
||||
if (customLocationInput) {
|
||||
customLocationInput.addEventListener('input', () => {
|
||||
updateLocationInfo();
|
||||
updateStepStatus();
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user