- 페이지 폴더 재구성: 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>
554 lines
15 KiB
JavaScript
554 lines
15 KiB
JavaScript
// 안전교육 진행 페이지 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;
|