feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
553
deploy/tkfb-package/web-ui/js/safety-training-conduct.js
Normal file
553
deploy/tkfb-package/web-ui/js/safety-training-conduct.js
Normal file
@@ -0,0 +1,553 @@
|
||||
// 안전교육 진행 페이지 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;
|
||||
Reference in New Issue
Block a user