- 실시간 작업장 현황을 지도로 시각화 - 작업장 관리 페이지에서 정의한 구역 정보 활용 - 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>
449 lines
15 KiB
JavaScript
449 lines
15 KiB
JavaScript
// 작업장 현황 JavaScript
|
|
|
|
let selectedCategory = null;
|
|
let workplaceData = [];
|
|
let mapRegions = []; // 작업장 영역 데이터
|
|
let canvas = null;
|
|
let ctx = null;
|
|
let canvasImage = null;
|
|
|
|
// 금일 TBM 작업자 데이터
|
|
let todayWorkers = [];
|
|
|
|
// 금일 출입 신청 데이터
|
|
let todayVisitors = [];
|
|
|
|
// ==================== 초기화 ====================
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await loadCategories();
|
|
|
|
// 이벤트 리스너
|
|
document.getElementById('categorySelect').addEventListener('change', onCategoryChange);
|
|
document.getElementById('refreshMapBtn').addEventListener('click', refreshMapData);
|
|
|
|
// 기본값으로 제1공장 선택
|
|
await selectFirstCategory();
|
|
});
|
|
|
|
// ==================== 카테고리 (공장) 로드 ====================
|
|
|
|
async function loadCategories() {
|
|
try {
|
|
const response = await window.apiCall('/workplaces/categories', 'GET');
|
|
|
|
if (response && response.success) {
|
|
const categories = response.data || [];
|
|
const select = document.getElementById('categorySelect');
|
|
|
|
categories.forEach(cat => {
|
|
const option = document.createElement('option');
|
|
option.value = cat.category_id;
|
|
option.textContent = cat.category_name;
|
|
option.dataset.layoutImage = cat.layout_image;
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('카테고리 로드 오류:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 첫 번째 카테고리 자동 선택
|
|
*/
|
|
async function selectFirstCategory() {
|
|
const select = document.getElementById('categorySelect');
|
|
if (select.options.length > 1) {
|
|
// 첫 번째 옵션 선택 (인덱스 0은 "공장을 선택하세요")
|
|
select.selectedIndex = 1;
|
|
// 변경 이벤트 트리거
|
|
await onCategoryChange({ target: select });
|
|
}
|
|
}
|
|
|
|
// ==================== 공장 선택 ====================
|
|
|
|
async function onCategoryChange(e) {
|
|
const categoryId = e.target.value;
|
|
|
|
if (!categoryId) {
|
|
document.getElementById('workplaceMapContainer').style.display = 'none';
|
|
document.getElementById('mapPlaceholder').style.display = 'flex';
|
|
return;
|
|
}
|
|
|
|
const selectedOption = e.target.options[e.target.selectedIndex];
|
|
const layoutImage = selectedOption.dataset.layoutImage;
|
|
|
|
selectedCategory = {
|
|
category_id: categoryId,
|
|
category_name: selectedOption.textContent,
|
|
layout_image: layoutImage
|
|
};
|
|
|
|
// 지도 로드
|
|
await loadWorkplaceMap();
|
|
|
|
// 금일 작업 데이터 로드
|
|
await loadTodayData();
|
|
|
|
// 지도 렌더링
|
|
renderMap();
|
|
}
|
|
|
|
// ==================== 작업장 지도 로드 ====================
|
|
|
|
async function loadWorkplaceMap() {
|
|
try {
|
|
// 작업장 데이터 로드
|
|
const response = await window.apiCall(`/workplaces?category_id=${selectedCategory.category_id}`, 'GET');
|
|
|
|
if (response && response.success) {
|
|
workplaceData = response.data || [];
|
|
}
|
|
|
|
// 작업장 영역 데이터 로드 (map-regions API)
|
|
const regionsResponse = await window.apiCall(`/workplaces/categories/${selectedCategory.category_id}/map-regions`, 'GET');
|
|
|
|
if (regionsResponse && regionsResponse.success) {
|
|
mapRegions = regionsResponse.data || [];
|
|
console.log('[지도] 로드된 영역:', mapRegions);
|
|
}
|
|
|
|
// 이미지 로드
|
|
await loadMapImage();
|
|
|
|
// 지도 컨테이너 표시
|
|
document.getElementById('mapPlaceholder').style.display = 'none';
|
|
document.getElementById('workplaceMapContainer').style.display = 'block';
|
|
} catch (error) {
|
|
console.error('작업장 데이터 로드 오류:', error);
|
|
}
|
|
}
|
|
|
|
async function loadMapImage() {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
|
|
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}`;
|
|
|
|
img.onload = () => {
|
|
canvasImage = img;
|
|
|
|
// 캔버스 초기화
|
|
canvas = document.getElementById('workplaceMapCanvas');
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
ctx = canvas.getContext('2d');
|
|
|
|
// 클릭 이벤트
|
|
canvas.addEventListener('click', onMapClick);
|
|
|
|
resolve();
|
|
};
|
|
|
|
img.onerror = () => {
|
|
console.error('이미지 로드 실패:', fullImageUrl);
|
|
reject();
|
|
};
|
|
|
|
img.src = fullImageUrl;
|
|
});
|
|
}
|
|
|
|
// ==================== 금일 데이터 로드 ====================
|
|
|
|
async function loadTodayData() {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
// TBM 작업자 데이터 로드
|
|
await loadTodayWorkers(today);
|
|
|
|
// 출입 신청 데이터 로드
|
|
await loadTodayVisitors(today);
|
|
}
|
|
|
|
async function loadTodayWorkers(date) {
|
|
try {
|
|
const response = await window.apiCall(`/tbm/sessions/date/${date}`, 'GET');
|
|
|
|
if (response && response.success) {
|
|
const sessions = response.data || [];
|
|
todayWorkers = [];
|
|
|
|
// 각 세션의 작업 정보 추가
|
|
sessions.forEach(session => {
|
|
if (session.workplace_id) {
|
|
const memberCount = session.team_member_count || 0;
|
|
const leaderCount = session.leader_id ? 1 : 0;
|
|
const totalCount = memberCount + leaderCount;
|
|
|
|
todayWorkers.push({
|
|
workplace_id: session.workplace_id,
|
|
task_name: session.task_name || '작업',
|
|
work_location: session.work_location || '',
|
|
member_count: totalCount,
|
|
project_name: session.project_name || ''
|
|
});
|
|
|
|
console.log(`[TBM] 작업 추가: ${session.work_location || session.workplace_id} - ${session.task_name} (${totalCount}명)`);
|
|
}
|
|
});
|
|
|
|
console.log('로드된 작업자:', todayWorkers);
|
|
}
|
|
} catch (error) {
|
|
console.error('TBM 작업자 데이터 로드 오류:', error);
|
|
}
|
|
}
|
|
|
|
async function loadTodayVisitors(date) {
|
|
try {
|
|
// 날짜 형식 확인 (YYYY-MM-DD)
|
|
const formattedDate = date.split('T')[0];
|
|
|
|
const response = await window.apiCall(`/workplace-visits/requests`, 'GET');
|
|
|
|
if (response && response.success) {
|
|
const requests = response.data || [];
|
|
|
|
// 금일 날짜와 승인된 요청 필터링
|
|
todayVisitors = requests.filter(req => {
|
|
const visitDate = new Date(req.visit_date).toISOString().split('T')[0];
|
|
return visitDate === formattedDate &&
|
|
(req.status === 'approved' || req.status === 'training_completed');
|
|
}).map(req => ({
|
|
workplace_id: req.workplace_id,
|
|
visitor_company: req.visitor_company,
|
|
visitor_count: req.visitor_count,
|
|
visit_time: req.visit_time,
|
|
purpose_name: req.purpose_name,
|
|
status: req.status
|
|
}));
|
|
|
|
console.log('로드된 방문자:', todayVisitors);
|
|
}
|
|
} catch (error) {
|
|
console.error('출입 신청 데이터 로드 오류:', error);
|
|
}
|
|
}
|
|
|
|
// ==================== 지도 렌더링 ====================
|
|
|
|
function renderMap() {
|
|
if (!canvas || !ctx || !canvasImage) return;
|
|
|
|
// 이미지 그리기
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.drawImage(canvasImage, 0, 0);
|
|
|
|
// 모든 작업장 영역 표시
|
|
mapRegions.forEach(region => {
|
|
// 해당 작업장의 작업자/방문자 인원 계산
|
|
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
|
|
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
|
|
|
|
const totalWorkerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
|
|
const totalVisitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
|
|
|
|
// 영역 그리기
|
|
drawWorkplaceRegion(region, totalWorkerCount, totalVisitorCount);
|
|
});
|
|
}
|
|
|
|
function drawWorkplaceRegion(region, workerCount, visitorCount) {
|
|
// 사각형 좌표 변환
|
|
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;
|
|
|
|
const width = x2 - x1;
|
|
const height = y2 - y1;
|
|
const centerX = x1 + width / 2;
|
|
const centerY = y1 + height / 2;
|
|
|
|
// 색상 결정
|
|
let fillColor, strokeColor;
|
|
const hasActivity = workerCount > 0 || visitorCount > 0;
|
|
|
|
if (workerCount > 0 && visitorCount > 0) {
|
|
// 둘 다 있음 - 초록색
|
|
fillColor = 'rgba(34, 197, 94, 0.3)';
|
|
strokeColor = 'rgb(34, 197, 94)';
|
|
} else if (workerCount > 0) {
|
|
// 내부 작업자만 - 파란색
|
|
fillColor = 'rgba(59, 130, 246, 0.3)';
|
|
strokeColor = 'rgb(59, 130, 246)';
|
|
} else if (visitorCount > 0) {
|
|
// 외부 방문자만 - 보라색
|
|
fillColor = 'rgba(168, 85, 247, 0.3)';
|
|
strokeColor = 'rgb(168, 85, 247)';
|
|
} else {
|
|
// 인원 없음 - 회색 테두리만
|
|
fillColor = 'rgba(0, 0, 0, 0)'; // 투명
|
|
strokeColor = 'rgb(156, 163, 175)'; // 회색
|
|
}
|
|
|
|
// 사각형 그리기
|
|
ctx.save();
|
|
ctx.fillStyle = fillColor;
|
|
ctx.fillRect(x1, y1, width, height);
|
|
ctx.strokeStyle = strokeColor;
|
|
ctx.lineWidth = hasActivity ? 3 : 2;
|
|
ctx.strokeRect(x1, y1, width, height);
|
|
ctx.restore();
|
|
|
|
// 인원수 표시 (인원이 있을 때만)
|
|
if (hasActivity) {
|
|
ctx.save();
|
|
ctx.font = 'bold 16px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
// 배경 원
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, 20, 0, Math.PI * 2);
|
|
ctx.fillStyle = 'white';
|
|
ctx.fill();
|
|
ctx.strokeStyle = strokeColor;
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
// 텍스트
|
|
const totalCount = workerCount + visitorCount;
|
|
ctx.fillStyle = strokeColor;
|
|
ctx.fillText(totalCount.toString(), centerX, centerY);
|
|
ctx.restore();
|
|
} else {
|
|
// 인원이 없을 때는 작업장 이름만 표시
|
|
ctx.save();
|
|
ctx.font = '12px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = 'rgb(107, 114, 128)';
|
|
ctx.fillText(region.workplace_name, centerX, centerY);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// ==================== 지도 클릭 ====================
|
|
|
|
function onMapClick(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) / rect.width * canvas.width;
|
|
const y = (e.clientY - rect.top) / rect.height * canvas.height;
|
|
|
|
// 클릭한 위치의 작업장 영역 찾기
|
|
for (const region of mapRegions) {
|
|
if (isPointInRegion(x, y, region)) {
|
|
// 작업장 정보를 찾아서 모달 표시
|
|
const workplace = workplaceData.find(w => w.workplace_id === region.workplace_id);
|
|
if (workplace) {
|
|
showWorkplaceDetail({ ...workplace, ...region });
|
|
} else {
|
|
// 작업장 정보가 없으면 region 데이터만 사용
|
|
showWorkplaceDetail(region);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isPointInRegion(x, y, 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;
|
|
|
|
return x >= x1 && x <= x2 && y >= y1 && y <= y2;
|
|
}
|
|
|
|
// ==================== 작업장 상세 정보 모달 ====================
|
|
|
|
function showWorkplaceDetail(workplace) {
|
|
const workers = todayWorkers.filter(w => w.workplace_id === workplace.workplace_id);
|
|
const visitors = todayVisitors.filter(v => v.workplace_id === workplace.workplace_id);
|
|
|
|
// 모달 제목
|
|
document.getElementById('modalWorkplaceName').textContent = `${selectedCategory.category_name} - ${workplace.workplace_name}`;
|
|
|
|
// 내부 작업자 목록
|
|
const workersList = document.getElementById('internalWorkersList');
|
|
if (workers.length === 0) {
|
|
workersList.innerHTML = '<p style="color: var(--gray-500); font-size: var(--text-sm);">금일 작업 예정 인원이 없습니다.</p>';
|
|
} else {
|
|
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
|
workers.forEach(worker => {
|
|
html += `
|
|
<div style="padding: 12px; background: var(--blue-50); border-left: 4px solid var(--blue-500); border-radius: var(--radius-md);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
<strong style="font-size: var(--text-base);">${worker.task_name}</strong>
|
|
<span style="margin-left: 8px; padding: 2px 8px; background: var(--blue-100); color: var(--blue-700); border-radius: var(--radius-sm); font-size: var(--text-xs);">${worker.member_count}명</span>
|
|
</div>
|
|
</div>
|
|
${worker.work_location ? `<div style="margin-top: 8px; font-size: var(--text-sm); color: var(--gray-600);">📍 ${worker.work_location}</div>` : ''}
|
|
${worker.project_name ? `<div style="margin-top: 4px; font-size: var(--text-sm); color: var(--gray-600);">📁 ${worker.project_name}</div>` : ''}
|
|
</div>
|
|
`;
|
|
});
|
|
html += '</div>';
|
|
workersList.innerHTML = html;
|
|
}
|
|
|
|
// 외부 방문자 목록
|
|
const visitorsList = document.getElementById('externalVisitorsList');
|
|
if (visitors.length === 0) {
|
|
visitorsList.innerHTML = '<p style="color: var(--gray-500); font-size: var(--text-sm);">금일 방문 예정 인원이 없습니다.</p>';
|
|
} else {
|
|
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
|
visitors.forEach(visitor => {
|
|
const statusText = visitor.status === 'training_completed' ? '교육 완료' : '승인됨';
|
|
const statusColor = visitor.status === 'training_completed' ? 'var(--green-500)' : 'var(--yellow-500)';
|
|
|
|
html += `
|
|
<div style="padding: 12px; background: var(--purple-50); border-left: 4px solid var(--purple-500); border-radius: var(--radius-md);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
<div>
|
|
<strong style="font-size: var(--text-base);">${visitor.visitor_company}</strong>
|
|
<span style="margin-left: 8px; padding: 2px 8px; background: var(--purple-100); color: var(--purple-700); border-radius: var(--radius-sm); font-size: var(--text-xs);">${visitor.visitor_count}명</span>
|
|
</div>
|
|
<span style="padding: 2px 8px; background: ${statusColor}20; color: ${statusColor}; border-radius: var(--radius-sm); font-size: var(--text-xs); font-weight: 600;">${statusText}</span>
|
|
</div>
|
|
<div style="font-size: var(--text-sm); color: var(--gray-600);">
|
|
<div>⏰ 방문 시간: ${visitor.visit_time}</div>
|
|
<div>📋 목적: ${visitor.purpose_name}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
html += '</div>';
|
|
visitorsList.innerHTML = html;
|
|
}
|
|
|
|
// 모달 표시
|
|
document.getElementById('workplaceDetailModal').style.display = 'flex';
|
|
}
|
|
|
|
function closeWorkplaceModal() {
|
|
document.getElementById('workplaceDetailModal').style.display = 'none';
|
|
}
|
|
|
|
// ==================== 새로고침 ====================
|
|
|
|
async function refreshMapData() {
|
|
if (!selectedCategory) return;
|
|
|
|
await loadTodayData();
|
|
renderMap();
|
|
}
|
|
|
|
// 전역 함수로 노출
|
|
window.closeWorkplaceModal = closeWorkplaceModal;
|