Files
TK-FB-Project/web-ui/js/safety-training-conduct.js
Hyungi Ahn 74d3a78aa3 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>
2026-02-02 14:27:22 +09:00

554 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 안전교육 진행 페이지 JavaScript
let requestId = null;
let requestData = null;
let canvas = null;
let ctx = null;
let isDrawing = false;
let hasSignature = false;
let savedSignatures = []; // 저장된 서명 목록
// ==================== Toast 알림 ====================
function showToast(message, type = 'info', duration = 3000) {
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const iconMap = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
toast.innerHTML = `
<span class="toast-icon">${iconMap[type] || ''}</span>
<span class="toast-message">${message}</span>
`;
toastContainer.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, duration);
}
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toastContainer';
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
`;
document.body.appendChild(container);
if (!document.getElementById('toastStyles')) {
const style = document.createElement('style');
style.id = 'toastStyles';
style.textContent = `
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0;
transform: translateX(100px);
transition: all 0.3s ease;
min-width: 250px;
max-width: 400px;
}
.toast.show {
opacity: 1;
transform: translateX(0);
}
.toast-success { border-left: 4px solid #10b981; }
.toast-error { border-left: 4px solid #ef4444; }
.toast-warning { border-left: 4px solid #f59e0b; }
.toast-info { border-left: 4px solid #3b82f6; }
.toast-icon { font-size: 20px; }
.toast-message { font-size: 14px; color: #374151; }
`;
document.head.appendChild(style);
}
return container;
}
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async () => {
// URL 파라미터에서 request_id 가져오기
const urlParams = new URLSearchParams(window.location.search);
requestId = urlParams.get('request_id');
if (!requestId) {
showToast('출입 신청 ID가 없습니다.', 'error');
setTimeout(() => {
window.location.href = '/pages/safety/management.html';
}, 2000);
return;
}
// 서명 캔버스 초기화
initSignatureCanvas();
// 현재 날짜 표시
const today = new Date().toLocaleDateString('ko-KR');
document.getElementById('signatureDate').textContent = today;
// 출입 신청 정보 로드
await loadRequestInfo();
});
// ==================== 출입 신청 정보 로드 ====================
/**
* 출입 신청 정보 로드
*/
async function loadRequestInfo() {
try {
const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET');
if (response && response.success) {
requestData = response.data;
// 상태 확인 - 승인됨 상태만 진행 가능
if (requestData.status !== 'approved') {
showToast('이미 처리되었거나 승인되지 않은 신청입니다.', 'error');
setTimeout(() => {
window.location.href = '/pages/safety/management.html';
}, 2000);
return;
}
renderRequestInfo();
} else {
throw new Error(response?.message || '정보를 불러올 수 없습니다.');
}
} catch (error) {
console.error('출입 신청 정보 로드 오류:', error);
showToast('출입 신청 정보를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 출입 신청 정보 렌더링
*/
function renderRequestInfo() {
const container = document.getElementById('requestInfo');
// 날짜 포맷 변환
const visitDate = new Date(requestData.visit_date);
const formattedDate = visitDate.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short'
});
const html = `
<div class="info-item">
<div class="info-label">신청 번호</div>
<div class="info-value">#${requestData.request_id}</div>
</div>
<div class="info-item">
<div class="info-label">신청자</div>
<div class="info-value">${requestData.requester_full_name || requestData.requester_name}</div>
</div>
<div class="info-item">
<div class="info-label">방문자 소속</div>
<div class="info-value">${requestData.visitor_company}</div>
</div>
<div class="info-item">
<div class="info-label">방문 인원</div>
<div class="info-value">${requestData.visitor_count}명</div>
</div>
<div class="info-item">
<div class="info-label">방문 작업장</div>
<div class="info-value">${requestData.category_name} - ${requestData.workplace_name}</div>
</div>
<div class="info-item">
<div class="info-label">방문 일시</div>
<div class="info-value">${formattedDate} ${requestData.visit_time}</div>
</div>
<div class="info-item">
<div class="info-label">방문 목적</div>
<div class="info-value">${requestData.purpose_name}</div>
</div>
`;
container.innerHTML = html;
}
// ==================== 서명 캔버스 ====================
/**
* 서명 캔버스 초기화
*/
function initSignatureCanvas() {
canvas = document.getElementById('signatureCanvas');
ctx = canvas.getContext('2d');
// 캔버스 크기 설정
const container = canvas.parentElement;
canvas.width = container.clientWidth - 4; // border 제외
canvas.height = 300;
// 그리기 설정
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 마우스 이벤트
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseout', stopDrawing);
// 터치 이벤트 (모바일, Apple Pencil)
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', stopDrawing);
canvas.addEventListener('touchcancel', stopDrawing);
// Pointer Events (Apple Pencil 최적화)
if (window.PointerEvent) {
canvas.addEventListener('pointerdown', handlePointerDown);
canvas.addEventListener('pointermove', handlePointerMove);
canvas.addEventListener('pointerup', stopDrawing);
canvas.addEventListener('pointercancel', stopDrawing);
}
}
/**
* 그리기 시작 (마우스)
*/
function startDrawing(e) {
isDrawing = true;
hasSignature = true;
document.getElementById('signaturePlaceholder').style.display = 'none';
const rect = canvas.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
}
/**
* 그리기 (마우스)
*/
function draw(e) {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
ctx.stroke();
}
/**
* 그리기 중지
*/
function stopDrawing() {
isDrawing = false;
ctx.beginPath();
}
/**
* 터치 시작 처리
*/
function handleTouchStart(e) {
e.preventDefault();
isDrawing = true;
hasSignature = true;
document.getElementById('signaturePlaceholder').style.display = 'none';
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(touch.clientX - rect.left, touch.clientY - rect.top);
}
/**
* 터치 이동 처리
*/
function handleTouchMove(e) {
if (!isDrawing) return;
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
ctx.lineTo(touch.clientX - rect.left, touch.clientY - rect.top);
ctx.stroke();
}
/**
* Pointer 시작 처리 (Apple Pencil)
*/
function handlePointerDown(e) {
isDrawing = true;
hasSignature = true;
document.getElementById('signaturePlaceholder').style.display = 'none';
const rect = canvas.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
}
/**
* Pointer 이동 처리 (Apple Pencil)
*/
function handlePointerMove(e) {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
ctx.stroke();
}
/**
* 서명 지우기
*/
function clearSignature() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
hasSignature = false;
document.getElementById('signaturePlaceholder').style.display = 'block';
}
/**
* 서명을 Base64로 변환
*/
function getSignatureBase64() {
if (!hasSignature) {
return null;
}
return canvas.toDataURL('image/png');
}
/**
* 현재 서명 저장
*/
function saveSignature() {
if (!hasSignature) {
showToast('서명이 없습니다. 이름과 서명을 작성해주세요.', 'warning');
return;
}
const signatureImage = getSignatureBase64();
const now = new Date();
savedSignatures.push({
id: Date.now(),
image: signatureImage,
timestamp: now.toLocaleString('ko-KR')
});
// 서명 카운트 업데이트
document.getElementById('signatureCount').textContent = savedSignatures.length;
// 캔버스 초기화
clearSignature();
// 저장된 서명 목록 렌더링
renderSavedSignatures();
// 교육 완료 버튼 활성화
updateCompleteButton();
showToast('서명이 저장되었습니다.', 'success');
}
/**
* 저장된 서명 목록 렌더링
*/
function renderSavedSignatures() {
const container = document.getElementById('savedSignatures');
if (savedSignatures.length === 0) {
container.innerHTML = '';
return;
}
let html = '<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--gray-700);">저장된 서명 목록</h3>';
savedSignatures.forEach((sig, index) => {
html += `
<div class="saved-signature-card">
<img src="${sig.image}" alt="서명 ${index + 1}">
<div class="saved-signature-info">
<div class="saved-signature-number">방문자 ${index + 1}</div>
<div class="saved-signature-date">저장 시간: ${sig.timestamp}</div>
</div>
<div class="saved-signature-actions">
<button type="button" class="btn btn-sm btn-danger" onclick="deleteSignature(${sig.id})">
삭제
</button>
</div>
</div>
`;
});
container.innerHTML = html;
}
/**
* 서명 삭제
*/
function deleteSignature(signatureId) {
if (!confirm('이 서명을 삭제하시겠습니까?')) {
return;
}
savedSignatures = savedSignatures.filter(sig => sig.id !== signatureId);
// 서명 카운트 업데이트
document.getElementById('signatureCount').textContent = savedSignatures.length;
// 목록 다시 렌더링
renderSavedSignatures();
// 교육 완료 버튼 상태 업데이트
updateCompleteButton();
showToast('서명이 삭제되었습니다.', 'success');
}
/**
* 교육 완료 버튼 활성화/비활성화
*/
function updateCompleteButton() {
const completeBtn = document.getElementById('completeBtn');
// 체크리스트와 서명이 모두 있어야 활성화
const checkboxes = document.querySelectorAll('input[name="safety-check"]');
const checkedItems = Array.from(checkboxes).filter(cb => cb.checked);
const allChecked = checkedItems.length === checkboxes.length;
const hasSignatures = savedSignatures.length > 0;
completeBtn.disabled = !(allChecked && hasSignatures);
}
// ==================== 교육 완료 처리 ====================
/**
* 교육 완료 처리
*/
async function completeTraining() {
// 체크리스트 검증
const checkboxes = document.querySelectorAll('input[name="safety-check"]');
const checkedItems = Array.from(checkboxes).filter(cb => cb.checked);
if (checkedItems.length !== checkboxes.length) {
showToast('모든 안전교육 항목을 체크해주세요.', 'warning');
return;
}
// 서명 검증
if (savedSignatures.length === 0) {
showToast('최소 1명 이상의 서명이 필요합니다.', 'warning');
return;
}
// 확인
if (!confirm(`${savedSignatures.length}명의 방문자 안전교육을 완료하시겠습니까?\n완료 후에는 수정할 수 없습니다.`)) {
return;
}
try {
// 교육 항목 수집
const trainingItems = checkedItems.map(cb => cb.value).join(', ');
// API 호출
const userData = localStorage.getItem('user');
const currentUser = userData ? JSON.parse(userData) : null;
if (!currentUser) {
showToast('로그인 정보를 찾을 수 없습니다.', 'error');
return;
}
// 현재 시간
const now = new Date();
const currentTime = now.toTimeString().split(' ')[0]; // HH:MM:SS
const trainingDate = now.toISOString().split('T')[0]; // YYYY-MM-DD
// 각 서명에 대해 개별적으로 API 호출
let successCount = 0;
for (let i = 0; i < savedSignatures.length; i++) {
const sig = savedSignatures[i];
const payload = {
request_id: requestId,
conducted_by: currentUser.user_id,
training_date: trainingDate,
training_start_time: currentTime,
training_end_time: currentTime,
training_items: trainingItems,
visitor_name: `방문자 ${i + 1}`, // 순번으로 구분
signature_image: sig.image,
notes: `교육 완료 - ${checkedItems.length}개 항목 (${i + 1}/${savedSignatures.length})`
};
const response = await window.apiCall(
'/workplace-visits/training',
'POST',
payload
);
if (response && response.success) {
successCount++;
} else {
console.error(`서명 ${i + 1} 저장 실패:`, response);
}
}
if (successCount === savedSignatures.length) {
showToast(`${successCount}명의 안전교육이 완료되었습니다.`, 'success');
setTimeout(() => {
window.location.href = '/pages/safety/management.html';
}, 1500);
} else if (successCount > 0) {
showToast(`${successCount}/${savedSignatures.length}명의 교육만 저장되었습니다.`, 'warning');
} else {
throw new Error('교육 완료 처리 실패');
}
} catch (error) {
console.error('교육 완료 처리 오류:', error);
showToast(error.message || '교육 완료 처리 중 오류가 발생했습니다.', 'error');
}
}
/**
* 뒤로 가기
*/
function goBack() {
if (hasSignature || document.querySelector('input[name="safety-check"]:checked')) {
if (!confirm('작성 중인 내용이 있습니다. 정말 나가시겠습니까?')) {
return;
}
}
window.location.href = '/pages/safety/management.html';
}
// 전역 함수로 노출
window.showToast = showToast;
window.clearSignature = clearSignature;
window.saveSignature = saveSignature;
window.deleteSignature = deleteSignature;
window.updateCompleteButton = updateCompleteButton;
window.completeTraining = completeTraining;
window.goBack = goBack;