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

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

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

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

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

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

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

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

View File

@@ -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);

View File

@@ -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 };

View File

@@ -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',

View File

@@ -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',
},
// 애플리케이션 관련 기타 설정

View File

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

View File

@@ -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);
}
});

View File

@@ -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 };

View 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;

View File

@@ -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}`;
}
// 전역 함수로 노출

View File

@@ -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';
}
// 전역 함수로 노출

View File

@@ -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';

View 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}`;
}

View 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();
});
}
});