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

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;