- 실시간 작업장 현황을 지도로 시각화 - 작업장 관리 페이지에서 정의한 구역 정보 활용 - 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>
532 lines
16 KiB
JavaScript
532 lines
16 KiB
JavaScript
// 출입 신청 페이지 JavaScript
|
||
|
||
let categories = [];
|
||
let workplaces = [];
|
||
let mapRegions = [];
|
||
let visitPurposes = [];
|
||
let selectedWorkplace = null;
|
||
let selectedCategory = null;
|
||
let canvas = null;
|
||
let ctx = null;
|
||
let layoutImage = null;
|
||
|
||
// ==================== Toast 알림 ====================
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
/**
|
||
* Toast 컨테이너 생성
|
||
*/
|
||
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);
|
||
|
||
// Toast 스타일 추가
|
||
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 () => {
|
||
// 오늘 날짜 기본값 설정
|
||
const today = new Date().toISOString().split('T')[0];
|
||
document.getElementById('visitDate').value = today;
|
||
document.getElementById('visitDate').min = today;
|
||
|
||
// 현재 시간 + 1시간 기본값 설정
|
||
const now = new Date();
|
||
now.setHours(now.getHours() + 1);
|
||
const timeString = now.toTimeString().slice(0, 5);
|
||
document.getElementById('visitTime').value = timeString;
|
||
|
||
// 데이터 로드
|
||
await loadCategories();
|
||
await loadVisitPurposes();
|
||
await loadMyRequests();
|
||
|
||
// 폼 제출 이벤트
|
||
document.getElementById('visitRequestForm').addEventListener('submit', handleSubmit);
|
||
|
||
// 캔버스 초기화
|
||
canvas = document.getElementById('workplaceMapCanvas');
|
||
ctx = canvas.getContext('2d');
|
||
});
|
||
|
||
// ==================== 데이터 로드 ====================
|
||
|
||
/**
|
||
* 카테고리(공장) 목록 로드
|
||
*/
|
||
async function loadCategories() {
|
||
try {
|
||
const response = await window.apiCall('/workplaces/categories', 'GET');
|
||
if (response && response.success) {
|
||
categories = response.data || [];
|
||
|
||
const categorySelect = document.getElementById('categorySelect');
|
||
categorySelect.innerHTML = '<option value="">구역을 선택하세요</option>';
|
||
|
||
categories.forEach(cat => {
|
||
if (cat.is_active) {
|
||
const option = document.createElement('option');
|
||
option.value = cat.category_id;
|
||
option.textContent = cat.category_name;
|
||
categorySelect.appendChild(option);
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('카테고리 로드 오류:', error);
|
||
window.showToast('카테고리 로드 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 방문 목적 목록 로드
|
||
*/
|
||
async function loadVisitPurposes() {
|
||
try {
|
||
const response = await window.apiCall('/workplace-visits/purposes/active', 'GET');
|
||
if (response && response.success) {
|
||
visitPurposes = response.data || [];
|
||
|
||
const purposeSelect = document.getElementById('visitPurpose');
|
||
purposeSelect.innerHTML = '<option value="">선택하세요</option>';
|
||
|
||
visitPurposes.forEach(purpose => {
|
||
const option = document.createElement('option');
|
||
option.value = purpose.purpose_id;
|
||
option.textContent = purpose.purpose_name;
|
||
purposeSelect.appendChild(option);
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('방문 목적 로드 오류:', error);
|
||
window.showToast('방문 목적 로드 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 내 출입 신청 목록 로드
|
||
*/
|
||
async function loadMyRequests() {
|
||
try {
|
||
// localStorage에서 사용자 정보 가져오기
|
||
const userData = localStorage.getItem('user');
|
||
const currentUser = userData ? JSON.parse(userData) : null;
|
||
|
||
if (!currentUser || !currentUser.user_id) {
|
||
console.log('사용자 정보 없음');
|
||
return;
|
||
}
|
||
|
||
const response = await window.apiCall(`/workplace-visits/requests?requester_id=${currentUser.user_id}`, 'GET');
|
||
|
||
if (response && response.success) {
|
||
const requests = response.data || [];
|
||
renderMyRequests(requests);
|
||
}
|
||
} catch (error) {
|
||
console.error('내 신청 목록 로드 오류:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 내 신청 목록 렌더링
|
||
*/
|
||
function renderMyRequests(requests) {
|
||
const listDiv = document.getElementById('myRequestsList');
|
||
|
||
if (requests.length === 0) {
|
||
listDiv.innerHTML = '<p style="text-align: center; color: var(--gray-500); padding: 32px;">신청 내역이 없습니다</p>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
requests.forEach(req => {
|
||
const statusText = {
|
||
'pending': '승인 대기',
|
||
'approved': '승인됨',
|
||
'rejected': '반려됨',
|
||
'training_completed': '교육 완료'
|
||
}[req.status] || req.status;
|
||
|
||
html += `
|
||
<div class="request-card">
|
||
<div class="request-card-header">
|
||
<h3 style="margin: 0; font-size: var(--text-lg);">${req.visitor_company} (${req.visitor_count}명)</h3>
|
||
<span class="request-status ${req.status}">${statusText}</span>
|
||
</div>
|
||
<div class="request-info">
|
||
<div class="info-item">
|
||
<span class="info-label">방문 작업장</span>
|
||
<span class="info-value">${req.category_name} - ${req.workplace_name}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">방문 일시</span>
|
||
<span class="info-value">${req.visit_date} ${req.visit_time}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">방문 목적</span>
|
||
<span class="info-value">${req.purpose_name}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">신청일</span>
|
||
<span class="info-value">${new Date(req.created_at).toLocaleDateString()}</span>
|
||
</div>
|
||
</div>
|
||
${req.rejection_reason ? `<p style="margin-top: 12px; padding: 12px; background: var(--red-50); color: var(--red-700); border-radius: var(--radius-md); font-size: var(--text-sm);"><strong>반려 사유:</strong> ${req.rejection_reason}</p>` : ''}
|
||
${req.notes ? `<p style="margin-top: 12px; color: var(--gray-600); font-size: var(--text-sm);"><strong>비고:</strong> ${req.notes}</p>` : ''}
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
listDiv.innerHTML = html;
|
||
}
|
||
|
||
// ==================== 작업장 지도 모달 ====================
|
||
|
||
/**
|
||
* 지도 모달 열기
|
||
*/
|
||
function openMapModal() {
|
||
document.getElementById('mapModal').style.display = 'flex';
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
/**
|
||
* 지도 모달 닫기
|
||
*/
|
||
function closeMapModal() {
|
||
document.getElementById('mapModal').style.display = 'none';
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
/**
|
||
* 작업장 지도 로드
|
||
*/
|
||
async function loadWorkplaceMap() {
|
||
const categoryId = document.getElementById('categorySelect').value;
|
||
if (!categoryId) {
|
||
document.getElementById('mapCanvasContainer').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
selectedCategory = categories.find(c => c.category_id == categoryId);
|
||
|
||
try {
|
||
// 작업장 목록 로드
|
||
const workplacesResponse = await window.apiCall(`/workplaces/categories/${categoryId}`, 'GET');
|
||
if (workplacesResponse && workplacesResponse.success) {
|
||
workplaces = workplacesResponse.data || [];
|
||
}
|
||
|
||
// 지도 영역 로드
|
||
const regionsResponse = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`, 'GET');
|
||
if (regionsResponse && regionsResponse.success) {
|
||
mapRegions = regionsResponse.data || [];
|
||
}
|
||
|
||
// 레이아웃 이미지가 있으면 표시
|
||
if (selectedCategory && selectedCategory.layout_image) {
|
||
// API_BASE_URL에서 /api 제거하고 이미지 경로 생성
|
||
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}`;
|
||
|
||
console.log('이미지 URL:', fullImageUrl);
|
||
loadImageToCanvas(fullImageUrl);
|
||
document.getElementById('mapCanvasContainer').style.display = 'block';
|
||
} else {
|
||
window.showToast('선택한 구역에 레이아웃 지도가 없습니다.', 'warning');
|
||
document.getElementById('mapCanvasContainer').style.display = 'none';
|
||
}
|
||
} catch (error) {
|
||
console.error('작업장 지도 로드 오류:', error);
|
||
window.showToast('작업장 지도 로드 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 이미지를 캔버스에 로드
|
||
*/
|
||
function loadImageToCanvas(imagePath) {
|
||
const img = new Image();
|
||
// crossOrigin 제거 - 같은 도메인이므로 불필요
|
||
img.onload = function() {
|
||
// 캔버스 크기 설정
|
||
const maxWidth = 800;
|
||
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
|
||
|
||
canvas.width = img.width * scale;
|
||
canvas.height = img.height * scale;
|
||
|
||
// 이미지 그리기
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
|
||
layoutImage = img;
|
||
|
||
// 영역 표시
|
||
drawRegions();
|
||
|
||
// 클릭 이벤트 등록
|
||
canvas.onclick = handleCanvasClick;
|
||
};
|
||
img.onerror = function() {
|
||
window.showToast('지도 이미지를 불러올 수 없습니다.', 'error');
|
||
};
|
||
img.src = imagePath;
|
||
}
|
||
|
||
/**
|
||
* 지도 영역 그리기
|
||
*/
|
||
function drawRegions() {
|
||
mapRegions.forEach(region => {
|
||
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;
|
||
|
||
// 영역 박스
|
||
ctx.strokeStyle = '#10b981';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||
|
||
ctx.fillStyle = 'rgba(16, 185, 129, 0.1)';
|
||
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||
|
||
// 작업장 이름
|
||
ctx.fillStyle = '#10b981';
|
||
ctx.font = 'bold 14px sans-serif';
|
||
ctx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 캔버스 클릭 핸들러
|
||
*/
|
||
function handleCanvasClick(event) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = event.clientX - rect.left;
|
||
const y = event.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;
|
||
}
|
||
}
|
||
|
||
window.showToast('작업장 영역을 클릭해주세요.', 'warning');
|
||
}
|
||
|
||
/**
|
||
* 작업장 선택
|
||
*/
|
||
function selectWorkplace(region) {
|
||
selectedWorkplace = {
|
||
workplace_id: region.workplace_id,
|
||
workplace_name: region.workplace_name,
|
||
category_id: selectedCategory.category_id,
|
||
category_name: selectedCategory.category_name
|
||
};
|
||
|
||
// 선택 표시
|
||
const selectionDiv = document.getElementById('workplaceSelection');
|
||
selectionDiv.classList.add('selected');
|
||
selectionDiv.innerHTML = `
|
||
<div class="icon">✅</div>
|
||
<div class="text">${selectedCategory.category_name} - ${region.workplace_name}</div>
|
||
`;
|
||
|
||
// 상세 정보 카드 표시
|
||
const infoDiv = document.getElementById('selectedWorkplaceInfo');
|
||
infoDiv.style.display = 'block';
|
||
infoDiv.innerHTML = `
|
||
<div class="workplace-info-card">
|
||
<div class="icon">📍</div>
|
||
<div class="details">
|
||
<div class="name">${region.workplace_name}</div>
|
||
<div class="category">${selectedCategory.category_name}</div>
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="clearWorkplaceSelection()">변경</button>
|
||
</div>
|
||
`;
|
||
|
||
// 모달 닫기
|
||
closeMapModal();
|
||
|
||
window.showToast(`${region.workplace_name} 작업장이 선택되었습니다.`, 'success');
|
||
}
|
||
|
||
/**
|
||
* 작업장 선택 초기화
|
||
*/
|
||
function clearWorkplaceSelection() {
|
||
selectedWorkplace = null;
|
||
|
||
const selectionDiv = document.getElementById('workplaceSelection');
|
||
selectionDiv.classList.remove('selected');
|
||
selectionDiv.innerHTML = `
|
||
<div class="icon">📍</div>
|
||
<div class="text">지도에서 작업장을 선택하세요</div>
|
||
`;
|
||
|
||
document.getElementById('selectedWorkplaceInfo').style.display = 'none';
|
||
}
|
||
|
||
// ==================== 폼 제출 ====================
|
||
|
||
/**
|
||
* 출입 신청 제출
|
||
*/
|
||
async function handleSubmit(event) {
|
||
event.preventDefault();
|
||
|
||
if (!selectedWorkplace) {
|
||
window.showToast('작업장을 선택해주세요.', 'warning');
|
||
openMapModal();
|
||
return;
|
||
}
|
||
|
||
const formData = {
|
||
visitor_company: document.getElementById('visitorCompany').value.trim(),
|
||
visitor_count: parseInt(document.getElementById('visitorCount').value),
|
||
category_id: selectedWorkplace.category_id,
|
||
workplace_id: selectedWorkplace.workplace_id,
|
||
visit_date: document.getElementById('visitDate').value,
|
||
visit_time: document.getElementById('visitTime').value,
|
||
purpose_id: parseInt(document.getElementById('visitPurpose').value),
|
||
notes: document.getElementById('notes').value.trim() || null
|
||
};
|
||
|
||
try {
|
||
const response = await window.apiCall('/workplace-visits/requests', 'POST', formData);
|
||
|
||
if (response && response.success) {
|
||
window.showToast('출입 신청 및 안전교육 신청이 완료되었습니다. 안전관리자의 승인을 기다려주세요.', 'success');
|
||
|
||
// 폼 초기화
|
||
resetForm();
|
||
|
||
// 내 신청 목록 새로고침
|
||
await loadMyRequests();
|
||
} else {
|
||
throw new Error(response?.message || '신청 실패');
|
||
}
|
||
} catch (error) {
|
||
console.error('출입 신청 오류:', error);
|
||
window.showToast(error.message || '출입 신청 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 폼 초기화
|
||
*/
|
||
function resetForm() {
|
||
document.getElementById('visitRequestForm').reset();
|
||
clearWorkplaceSelection();
|
||
|
||
// 오늘 날짜와 시간 다시 설정
|
||
const today = new Date().toISOString().split('T')[0];
|
||
document.getElementById('visitDate').value = today;
|
||
|
||
const now = new Date();
|
||
now.setHours(now.getHours() + 1);
|
||
const timeString = now.toTimeString().slice(0, 5);
|
||
document.getElementById('visitTime').value = timeString;
|
||
|
||
document.getElementById('visitorCount').value = 1;
|
||
}
|
||
|
||
// 전역 함수로 노출
|
||
window.showToast = showToast;
|
||
window.openMapModal = openMapModal;
|
||
window.closeMapModal = closeMapModal;
|
||
window.loadWorkplaceMap = loadWorkplaceMap;
|
||
window.clearWorkplaceSelection = clearWorkplaceSelection;
|
||
window.resetForm = resetForm;
|