- 실시간 작업장 현황을 지도로 시각화 - 작업장 관리 페이지에서 정의한 구역 정보 활용 - 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>
495 lines
15 KiB
JavaScript
495 lines
15 KiB
JavaScript
// 작업장 레이아웃 지도 관리
|
|
|
|
// 전역 변수
|
|
let layoutMapImage = null;
|
|
let mapRegions = [];
|
|
let canvas = null;
|
|
let ctx = null;
|
|
let isDrawing = false;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let currentRect = null;
|
|
|
|
// ==================== 레이아웃 지도 모달 ====================
|
|
|
|
/**
|
|
* 레이아웃 지도 모달 열기
|
|
*/
|
|
async function openLayoutMapModal() {
|
|
// window 객체에서 currentCategoryId 가져오기
|
|
const currentCategoryId = window.currentCategoryId;
|
|
|
|
if (!currentCategoryId) {
|
|
window.window.showToast('공장을 먼저 선택해주세요.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const modal = document.getElementById('layoutMapModal');
|
|
if (!modal) return;
|
|
|
|
// 캔버스 초기화
|
|
canvas = document.getElementById('regionCanvas');
|
|
ctx = canvas.getContext('2d');
|
|
|
|
// 현재 카테고리의 레이아웃 이미지 및 영역 로드
|
|
await loadLayoutMapData();
|
|
|
|
// 작업장 선택 옵션 업데이트
|
|
updateWorkplaceSelect();
|
|
|
|
modal.style.display = 'flex';
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 지도 모달 닫기
|
|
*/
|
|
function closeLayoutMapModal() {
|
|
const modal = document.getElementById('layoutMapModal');
|
|
if (modal) {
|
|
modal.style.display = 'none';
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
// 캔버스 이벤트 리스너 제거
|
|
if (canvas) {
|
|
canvas.removeEventListener('mousedown', startDrawing);
|
|
canvas.removeEventListener('mousemove', draw);
|
|
canvas.removeEventListener('mouseup', stopDrawing);
|
|
}
|
|
|
|
// 메인 페이지의 레이아웃 미리보기 업데이트
|
|
const currentCategoryId = window.currentCategoryId;
|
|
const categories = window.categories;
|
|
|
|
if (currentCategoryId && categories) {
|
|
const category = categories.find(c => c.category_id == currentCategoryId);
|
|
if (category && window.updateLayoutPreview) {
|
|
window.updateLayoutPreview(category);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 지도 데이터 로드
|
|
*/
|
|
async function loadLayoutMapData() {
|
|
try {
|
|
const currentCategoryId = window.currentCategoryId;
|
|
const categories = window.categories;
|
|
|
|
// 현재 카테고리 정보 가져오기
|
|
const category = categories.find(c => c.category_id == currentCategoryId);
|
|
if (!category) return;
|
|
|
|
// 레이아웃 이미지 표시
|
|
const currentImageDiv = document.getElementById('currentLayoutImage');
|
|
if (category.layout_image) {
|
|
// 이미지 경로를 전체 URL로 변환
|
|
const fullImageUrl = category.layout_image.startsWith('http')
|
|
? category.layout_image
|
|
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
|
|
|
|
currentImageDiv.innerHTML = `
|
|
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
|
|
`;
|
|
|
|
// 캔버스에도 이미지 로드
|
|
loadImageToCanvas(fullImageUrl);
|
|
} else {
|
|
currentImageDiv.innerHTML = '<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>';
|
|
}
|
|
|
|
// 영역 데이터 로드
|
|
const regionsResponse = await window.apiCall(`/workplaces/categories/${currentCategoryId}/map-regions`, 'GET');
|
|
if (regionsResponse && regionsResponse.success) {
|
|
mapRegions = regionsResponse.data || [];
|
|
} else {
|
|
mapRegions = [];
|
|
}
|
|
|
|
renderRegionList();
|
|
} catch (error) {
|
|
console.error('레이아웃 지도 데이터 로딩 오류:', error);
|
|
window.window.showToast('레이아웃 지도 데이터를 불러오는데 실패했습니다.', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 이미지를 캔버스에 로드
|
|
*/
|
|
function loadImageToCanvas(imagePath) {
|
|
const img = new Image();
|
|
img.onload = function() {
|
|
// 캔버스 크기를 이미지 크기에 맞춤 (최대 800px)
|
|
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);
|
|
|
|
layoutMapImage = img;
|
|
|
|
// 기존 영역들 그리기
|
|
drawExistingRegions();
|
|
|
|
// 캔버스 이벤트 리스너 등록
|
|
setupCanvasEvents();
|
|
};
|
|
img.src = imagePath;
|
|
}
|
|
|
|
/**
|
|
* 작업장 선택 옵션 업데이트
|
|
*/
|
|
function updateWorkplaceSelect() {
|
|
const select = document.getElementById('regionWorkplaceSelect');
|
|
if (!select) return;
|
|
|
|
const currentCategoryId = window.currentCategoryId;
|
|
const workplaces = window.workplaces;
|
|
|
|
// 현재 카테고리의 작업장만 필터링
|
|
const categoryWorkplaces = workplaces.filter(w => w.category_id == currentCategoryId);
|
|
|
|
let options = '<option value="">작업장을 선택하세요</option>';
|
|
categoryWorkplaces.forEach(wp => {
|
|
// 이미 영역이 정의된 작업장은 표시
|
|
const hasRegion = mapRegions.some(r => r.workplace_id === wp.workplace_id);
|
|
options += `<option value="${wp.workplace_id}">${wp.workplace_name}${hasRegion ? ' (영역 정의됨)' : ''}</option>`;
|
|
});
|
|
|
|
select.innerHTML = options;
|
|
}
|
|
|
|
// ==================== 이미지 업로드 ====================
|
|
|
|
/**
|
|
* 이미지 미리보기
|
|
*/
|
|
function previewLayoutImage(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
const currentImageDiv = document.getElementById('currentLayoutImage');
|
|
currentImageDiv.innerHTML = `
|
|
<img src="${e.target.result}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="미리보기">
|
|
<p style="color: #64748b; font-size: 14px; margin-top: 8px;">미리보기 (저장하려면 "이미지 업로드" 버튼을 클릭하세요)</p>
|
|
`;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 이미지 업로드
|
|
*/
|
|
async function uploadLayoutImage() {
|
|
const fileInput = document.getElementById('layoutImageFile');
|
|
const file = fileInput.files[0];
|
|
|
|
if (!file) {
|
|
window.showToast('이미지 파일을 선택해주세요.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const currentCategoryId = window.currentCategoryId;
|
|
|
|
if (!currentCategoryId) {
|
|
window.showToast('공장을 먼저 선택해주세요.', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// FormData 생성
|
|
const formData = new FormData();
|
|
formData.append('image', file);
|
|
|
|
// 업로드 요청
|
|
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:20005/api'}/workplaces/categories/${currentCategoryId}/layout-image`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
window.showToast('이미지가 성공적으로 업로드되었습니다.', 'success');
|
|
|
|
// 이미지 경로를 전체 URL로 변환
|
|
const fullImageUrl = `${window.API_BASE_URL || 'http://localhost:20005/api'}${result.data.image_path}`.replace('/api/', '/');
|
|
|
|
// 이미지를 캔버스에 로드
|
|
loadImageToCanvas(fullImageUrl);
|
|
|
|
// 현재 이미지 미리보기도 업데이트
|
|
const currentImageDiv = document.getElementById('currentLayoutImage');
|
|
if (currentImageDiv) {
|
|
currentImageDiv.innerHTML = `
|
|
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
|
|
`;
|
|
}
|
|
|
|
// 카테고리 데이터 새로고침 (workplace-management.js의 loadCategories 함수 호출)
|
|
if (window.loadCategories) {
|
|
await window.loadCategories();
|
|
|
|
// 메인 페이지 미리보기도 업데이트
|
|
const currentCategoryId = window.currentCategoryId;
|
|
const categories = window.categories;
|
|
if (currentCategoryId && categories && window.updateLayoutPreview) {
|
|
const category = categories.find(c => c.category_id == currentCategoryId);
|
|
if (category) {
|
|
window.updateLayoutPreview(category);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error(result.message || '업로드 실패');
|
|
}
|
|
} catch (error) {
|
|
console.error('이미지 업로드 오류:', error);
|
|
window.showToast(error.message || '이미지 업로드 중 오류가 발생했습니다.', 'error');
|
|
}
|
|
}
|
|
|
|
// ==================== 영역 그리기 ====================
|
|
|
|
/**
|
|
* 캔버스 이벤트 설정
|
|
*/
|
|
function setupCanvasEvents() {
|
|
canvas.addEventListener('mousedown', startDrawing);
|
|
canvas.addEventListener('mousemove', draw);
|
|
canvas.addEventListener('mouseup', stopDrawing);
|
|
canvas.addEventListener('mouseleave', stopDrawing);
|
|
}
|
|
|
|
/**
|
|
* 그리기 시작
|
|
*/
|
|
function startDrawing(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
startX = e.clientX - rect.left;
|
|
startY = e.clientY - rect.top;
|
|
isDrawing = true;
|
|
}
|
|
|
|
/**
|
|
* 그리기
|
|
*/
|
|
function draw(e) {
|
|
if (!isDrawing) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const currentX = e.clientX - rect.left;
|
|
const currentY = e.clientY - rect.top;
|
|
|
|
// 캔버스 다시 그리기
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
if (layoutMapImage) {
|
|
ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height);
|
|
}
|
|
|
|
// 기존 영역들 그리기
|
|
drawExistingRegions();
|
|
|
|
// 현재 그리는 사각형
|
|
const width = currentX - startX;
|
|
const height = currentY - startY;
|
|
|
|
ctx.strokeStyle = '#3b82f6';
|
|
ctx.lineWidth = 3;
|
|
ctx.strokeRect(startX, startY, width, height);
|
|
|
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
|
|
ctx.fillRect(startX, startY, width, height);
|
|
|
|
currentRect = { startX, startY, endX: currentX, endY: currentY };
|
|
}
|
|
|
|
/**
|
|
* 그리기 종료
|
|
*/
|
|
function stopDrawing() {
|
|
isDrawing = false;
|
|
}
|
|
|
|
/**
|
|
* 기존 영역들 그리기
|
|
*/
|
|
function drawExistingRegions() {
|
|
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.15)';
|
|
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
|
|
|
// 작업장 이름 표시
|
|
ctx.fillStyle = '#10b981';
|
|
ctx.font = '14px sans-serif';
|
|
ctx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 현재 영역 지우기
|
|
*/
|
|
function clearCurrentRegion() {
|
|
currentRect = null;
|
|
|
|
// 캔버스 다시 그리기
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
if (layoutMapImage) {
|
|
ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height);
|
|
}
|
|
drawExistingRegions();
|
|
}
|
|
|
|
/**
|
|
* 영역 저장
|
|
*/
|
|
async function saveRegion() {
|
|
const workplaceId = document.getElementById('regionWorkplaceSelect').value;
|
|
|
|
if (!workplaceId) {
|
|
window.showToast('작업장을 선택해주세요.', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!currentRect) {
|
|
window.showToast('영역을 그려주세요.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const currentCategoryId = window.currentCategoryId;
|
|
|
|
try {
|
|
// 비율로 변환 (0~100%)
|
|
const xStart = Math.min(currentRect.startX, currentRect.endX) / canvas.width * 100;
|
|
const yStart = Math.min(currentRect.startY, currentRect.endY) / canvas.height * 100;
|
|
const xEnd = Math.max(currentRect.startX, currentRect.endX) / canvas.width * 100;
|
|
const yEnd = Math.max(currentRect.startY, currentRect.endY) / canvas.height * 100;
|
|
|
|
// 기존 영역이 있는지 확인
|
|
const existingRegion = mapRegions.find(r => r.workplace_id == workplaceId);
|
|
|
|
const regionData = {
|
|
workplace_id: parseInt(workplaceId),
|
|
category_id: parseInt(currentCategoryId),
|
|
x_start: xStart.toFixed(2),
|
|
y_start: yStart.toFixed(2),
|
|
x_end: xEnd.toFixed(2),
|
|
y_end: yEnd.toFixed(2),
|
|
shape: 'rect'
|
|
};
|
|
|
|
let response;
|
|
if (existingRegion) {
|
|
// 수정
|
|
response = await window.apiCall(`/workplaces/map-regions/${existingRegion.region_id}`, 'PUT', regionData);
|
|
} else {
|
|
// 신규 등록
|
|
response = await window.apiCall('/workplaces/map-regions', 'POST', regionData);
|
|
}
|
|
|
|
if (response && response.success) {
|
|
window.showToast('영역이 성공적으로 저장되었습니다.', 'success');
|
|
|
|
// 데이터 새로고침
|
|
await loadLayoutMapData();
|
|
|
|
// 현재 그림 초기화
|
|
clearCurrentRegion();
|
|
|
|
// 작업장 선택 초기화
|
|
document.getElementById('regionWorkplaceSelect').value = '';
|
|
} else {
|
|
throw new Error(response?.message || '저장 실패');
|
|
}
|
|
} catch (error) {
|
|
console.error('영역 저장 오류:', error);
|
|
window.showToast(error.message || '영역 저장 중 오류가 발생했습니다.', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 영역 목록 렌더링
|
|
*/
|
|
function renderRegionList() {
|
|
const listDiv = document.getElementById('regionList');
|
|
if (!listDiv) return;
|
|
|
|
if (mapRegions.length === 0) {
|
|
listDiv.innerHTML = '<p style="color: #94a3b8; text-align: center; padding: 20px;">정의된 영역이 없습니다</p>';
|
|
return;
|
|
}
|
|
|
|
let listHtml = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
|
|
|
mapRegions.forEach(region => {
|
|
listHtml += `
|
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: white; border: 1px solid #e5e7eb; border-radius: 6px;">
|
|
<div>
|
|
<span style="font-weight: 600; color: #1e293b;">${region.workplace_name}</span>
|
|
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">
|
|
(${region.x_start}%, ${region.y_start}%) ~ (${region.x_end}%, ${region.y_end}%)
|
|
</span>
|
|
</div>
|
|
<button onclick="deleteRegion(${region.region_id})" class="btn-small btn-delete" style="padding: 4px 8px; font-size: 12px;">
|
|
🗑️ 삭제
|
|
</button>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
listHtml += '</div>';
|
|
listDiv.innerHTML = listHtml;
|
|
}
|
|
|
|
/**
|
|
* 영역 삭제
|
|
*/
|
|
async function deleteRegion(regionId) {
|
|
if (!confirm('이 영역을 삭제하시겠습니까?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await window.apiCall(`/workplaces/map-regions/${regionId}`, 'DELETE');
|
|
|
|
if (response && response.success) {
|
|
window.showToast('영역이 삭제되었습니다.', 'success');
|
|
await loadLayoutMapData();
|
|
} else {
|
|
throw new Error(response?.message || '삭제 실패');
|
|
}
|
|
} catch (error) {
|
|
console.error('영역 삭제 오류:', error);
|
|
window.showToast(error.message || '영역 삭제 중 오류가 발생했습니다.', 'error');
|
|
}
|
|
}
|
|
|
|
// 전역 함수로 노출
|
|
window.openLayoutMapModal = openLayoutMapModal;
|
|
window.closeLayoutMapModal = closeLayoutMapModal;
|
|
window.previewLayoutImage = previewLayoutImage;
|
|
window.uploadLayoutImage = uploadLayoutImage;
|
|
window.clearCurrentRegion = clearCurrentRegion;
|
|
window.saveRegion = saveRegion;
|
|
window.deleteRegion = deleteRegion;
|