Files
TK-FB-Project/web-ui/js/visit-request.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

532 lines
16 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 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;