Files
TK-FB-Project/web-ui/js/safety-training-conduct.js
Hyungi Ahn b6485e3140 feat: 대시보드 작업장 현황 지도 구현
- 실시간 작업장 현황을 지도로 시각화
- 작업장 관리 페이지에서 정의한 구역 정보 활용
- TBM 작업자 및 방문자 현황 표시

주요 변경사항:
- dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거)
- workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현
- modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가

시각화 방식:
- 인원 없음: 회색 테두리 + 작업장 이름
- 내부 작업자: 파란색 영역 + 인원 수
- 외부 방문자: 보라색 영역 + 인원 수
- 둘 다: 초록색 영역 + 총 인원 수

기술 구현:
- Canvas API 기반 사각형 영역 렌더링
- map-regions API를 통한 데이터 일관성 보장
- 클릭 이벤트로 상세 정보 모달 표시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 15:46:47 +09:00

554 lines
15 KiB
JavaScript
Raw 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/admin/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/admin/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/admin/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/admin/safety-management.html';
}
// 전역 함수로 노출
window.showToast = showToast;
window.clearSignature = clearSignature;
window.saveSignature = saveSignature;
window.deleteSignature = deleteSignature;
window.updateCompleteButton = updateCompleteButton;
window.completeTraining = completeTraining;
window.goBack = goBack;