Files
TK-FB-Project/web-ui/js/workplace-status.js
Hyungi Ahn 4d83f10b07 feat: 설비 상세 패널 및 임시 이동 기능 구현
- 설비 마커 클릭 시 슬라이드 패널로 상세 정보 표시
- 설비 사진 업로드/삭제 기능
- 설비 임시 이동 기능 (3단계 지도 기반 선택)
  - Step 1: 공장 선택
  - Step 2: 레이아웃 지도에서 작업장 선택
  - Step 3: 상세 지도에서 위치 선택
- 설비 외부 반출/반입 기능
- 설비 수리 신청 기능 (기존 신고 시스템 연동)
- DB 마이그레이션 추가 (사진, 임시이동, 외부반출 테이블)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 13:45:56 +09:00

1569 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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 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() {
// 로컬 시간대 기준으로 오늘 날짜 구하기 (UTC가 아닌 한국 시간 기준)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const today = `${year}-${month}-${day}`;
console.log('[대시보드] 조회 날짜 (로컬):', today);
// 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 => {
// UTC 변환 없이 로컬 날짜로 비교
const visitDateObj = new Date(req.visit_date);
const visitYear = visitDateObj.getFullYear();
const visitMonth = String(visitDateObj.getMonth() + 1).padStart(2, '0');
const visitDay = String(visitDateObj.getDate()).padStart(2, '0');
const visitDate = `${visitYear}-${visitMonth}-${visitDay}`;
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;
}
// ==================== 작업장 상세 정보 모달 ====================
// 현재 선택된 작업장 정보 (모달용)
let currentModalWorkplace = null;
function showWorkplaceDetail(workplace) {
currentModalWorkplace = 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 = workplace.workplace_name;
document.getElementById('modalWorkplaceDesc').textContent = `${selectedCategory.category_name}${workplace.description || ''}`;
// 요약 카드 업데이트
const totalWorkers = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
const totalVisitors = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
document.getElementById('summaryWorkerCount').textContent = totalWorkers;
document.getElementById('summaryVisitorCount').textContent = totalVisitors;
document.getElementById('summaryTaskCount').textContent = workers.length;
// 배지 업데이트
document.getElementById('workerCountBadge').textContent = totalWorkers;
document.getElementById('visitorCountBadge').textContent = totalVisitors;
// 현황 개요 탭 - 현재 작업 목록
renderCurrentTasks(workers);
// 현황 개요 탭 - 설비 현황
loadEquipmentStatus(workplace.workplace_id);
// 작업자 탭
renderWorkersTab(workers);
// 방문자 탭
renderVisitorsTab(visitors);
// 상세 지도 초기화
initDetailMap(workplace);
// 첫 번째 탭 활성화
switchWorkplaceTab('overview');
// 모달 표시
document.getElementById('workplaceDetailModal').style.display = 'flex';
}
// 현재 작업 목록 렌더링
function renderCurrentTasks(workers) {
const container = document.getElementById('currentTasksList');
if (workers.length === 0) {
container.innerHTML = '<p class="empty-message">현재 진행 중인 작업이 없습니다.</p>';
return;
}
let html = '';
workers.forEach(worker => {
html += `
<div class="current-task-item">
<div class="task-info">
<p class="task-name">${worker.task_name}</p>
<p class="task-detail">
${worker.work_location ? `📍 ${worker.work_location}` : ''}
${worker.project_name ? ` • 📁 ${worker.project_name}` : ''}
</p>
</div>
<div class="task-badge">
<span>👷</span>
<span>${worker.member_count}명</span>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 설비 현황 로드
async function loadEquipmentStatus(workplaceId) {
const container = document.getElementById('equipmentSummary');
try {
const response = await window.apiCall(`/equipments?workplace_id=${workplaceId}`, 'GET');
if (response && response.success && response.data && response.data.length > 0) {
const equipments = response.data;
let html = '';
// 최대 4개만 표시
equipments.slice(0, 4).forEach(eq => {
const statusClass = eq.status === 'active' ? 'normal' :
eq.status === 'maintenance' ? 'warning' : 'error';
const statusText = eq.status === 'active' ? '정상' :
eq.status === 'maintenance' ? '점검중' : '수리필요';
html += `
<div class="equipment-item">
<span class="equipment-icon">⚙️</span>
<div class="equipment-info">
<p class="equipment-name">${eq.equipment_name}</p>
<p class="equipment-status ${statusClass}">${statusText}</p>
</div>
</div>
`;
});
if (equipments.length > 4) {
html += `<div class="equipment-item" style="justify-content: center; color: #6b7280; font-size: 0.85rem;">+${equipments.length - 4}개 더...</div>`;
}
container.innerHTML = html;
} else {
container.innerHTML = '<p class="empty-message">등록된 설비가 없습니다.</p>';
}
} catch (error) {
console.error('설비 현황 로드 오류:', error);
container.innerHTML = '<p class="empty-message">설비 정보를 불러올 수 없습니다.</p>';
}
}
// 작업자 탭 렌더링
function renderWorkersTab(workers) {
const container = document.getElementById('internalWorkersList');
if (workers.length === 0) {
container.innerHTML = '<p class="empty-message">금일 작업 예정 인원이 없습니다.</p>';
return;
}
let html = '';
workers.forEach(worker => {
html += `
<div class="worker-item">
<div class="worker-item-header">
<p class="worker-item-title">${worker.task_name}</p>
<span class="worker-item-badge">${worker.member_count}명</span>
</div>
${worker.work_location ? `<p class="worker-item-detail">📍 ${worker.work_location}</p>` : ''}
${worker.project_name ? `<p class="worker-item-detail">📁 ${worker.project_name}</p>` : ''}
</div>
`;
});
container.innerHTML = html;
}
// 방문자 탭 렌더링
function renderVisitorsTab(visitors) {
const container = document.getElementById('externalVisitorsList');
if (visitors.length === 0) {
container.innerHTML = '<p class="empty-message">금일 방문 예정 인원이 없습니다.</p>';
return;
}
let html = '';
visitors.forEach(visitor => {
const statusText = visitor.status === 'training_completed' ? '교육 완료' : '승인됨';
html += `
<div class="visitor-item">
<div class="visitor-item-header">
<p class="visitor-item-title">${visitor.visitor_company}</p>
<span class="visitor-item-badge">${visitor.visitor_count}명 • ${statusText}</span>
</div>
<p class="visitor-item-detail">⏰ ${visitor.visit_time}</p>
<p class="visitor-item-detail">📋 ${visitor.purpose_name}</p>
</div>
`;
});
container.innerHTML = html;
}
// 상세 지도 초기화
async function initDetailMap(workplace) {
const container = document.getElementById('detailMapContainer');
const legendContainer = document.getElementById('detailMapLegend');
// 작업장에 레이아웃 이미지가 있는지 확인
if (workplace.layout_image) {
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
const imageUrl = workplace.layout_image.startsWith('http')
? workplace.layout_image
: `${baseUrl}${workplace.layout_image}`;
// 이미지를 먼저 로드하여 비율 계산
const img = new Image();
img.onload = async () => {
// 이미지 래퍼를 생성하여 이미지와 마커가 같은 영역을 공유하도록 함
container.innerHTML = `
<div id="mapImageWrapper" class="map-image-wrapper">
<img src="${imageUrl}" alt="${workplace.workplace_name} 상세 지도" />
<div id="equipmentMarkersLayer" class="equipment-markers-layer"></div>
</div>
<div id="mapErrorPlaceholder" class="detail-map-placeholder" style="display: none;">
<span class="placeholder-icon">🗺️</span>
<p>지도 이미지를 불러올 수 없습니다.</p>
</div>
`;
// 설비 마커 로드
await loadEquipmentMarkers(workplace.workplace_id);
};
img.onerror = () => {
container.innerHTML = `
<div class="detail-map-placeholder">
<span class="placeholder-icon">🗺️</span>
<p>지도 이미지를 불러올 수 없습니다.</p>
</div>
`;
};
img.src = imageUrl;
// 범례 표시
legendContainer.innerHTML = `
<div class="map-legend-item">
<div class="legend-color" style="background: rgba(34, 197, 94, 0.5); border-color: #22c55e;"></div>
<span>정상 가동</span>
</div>
<div class="map-legend-item">
<div class="legend-color" style="background: rgba(245, 158, 11, 0.5); border-color: #f59e0b;"></div>
<span>점검 중</span>
</div>
<div class="map-legend-item">
<div class="legend-color" style="background: rgba(239, 68, 68, 0.5); border-color: #ef4444;"></div>
<span>수리 필요</span>
</div>
<div class="map-legend-item">
<div class="legend-color" style="background: rgba(156, 163, 175, 0.5); border-color: #9ca3af;"></div>
<span>비활성</span>
</div>
`;
} else {
container.innerHTML = `
<div class="detail-map-placeholder">
<span class="placeholder-icon">🗺️</span>
<p>상세 지도가 등록되지 않았습니다.</p>
<p style="font-size: 0.8rem; margin-top: 8px;">작업장 관리에서 레이아웃 이미지를 등록해주세요.</p>
</div>
`;
legendContainer.innerHTML = '';
}
}
// 설비 마커 로드 및 표시
async function loadEquipmentMarkers(workplaceId) {
const markersLayer = document.getElementById('equipmentMarkersLayer');
if (!markersLayer) return;
try {
const response = await window.apiCall(`/equipments?workplace_id=${workplaceId}`, 'GET');
if (response && response.success && response.data && response.data.length > 0) {
const equipments = response.data;
let markersHtml = '';
equipments.forEach(eq => {
// 위치 정보가 있는 설비만 마커 표시
if (eq.map_x_percent != null && eq.map_y_percent != null) {
const statusClass = eq.status === 'active' ? 'active' :
eq.status === 'maintenance' ? 'maintenance' :
eq.status === 'repair_needed' ? 'repair' : 'inactive';
// 마커 크기 (기본값 또는 설정된 값)
const width = eq.map_width_percent || 8;
const height = eq.map_height_percent || 6;
// 표시 이름: [코드] 이름
const displayName = `[${eq.equipment_code}] ${eq.equipment_name}`;
markersHtml += `
<div class="equipment-marker ${statusClass}"
style="left: ${eq.map_x_percent}%; top: ${eq.map_y_percent}%;
width: ${width}%; height: ${height}%;"
title="${displayName}"
onclick="openEquipmentPanel(${JSON.stringify(eq).replace(/"/g, '&quot;')})">
<span class="marker-label">${displayName}</span>
</div>
`;
}
});
if (markersHtml) {
markersLayer.innerHTML = markersHtml;
} else {
markersLayer.innerHTML = '<div class="no-markers-message">위치가 등록된 설비가 없습니다.</div>';
}
} else {
markersLayer.innerHTML = '<div class="no-markers-message">등록된 설비가 없습니다.</div>';
}
} catch (error) {
console.error('설비 마커 로드 오류:', error);
markersLayer.innerHTML = '<div class="no-markers-message">설비 정보를 불러올 수 없습니다.</div>';
}
}
// 설비 툴팁 표시
function showEquipmentTooltip(event, equipment) {
event.stopPropagation();
// 기존 툴팁 제거
const existingTooltip = document.querySelector('.equipment-tooltip');
if (existingTooltip) {
existingTooltip.remove();
}
const statusText = equipment.status === 'active' ? '정상 가동' :
equipment.status === 'maintenance' ? '점검 중' :
equipment.status === 'repair_needed' ? '수리 필요' : '비활성';
const statusClass = equipment.status === 'active' ? 'active' :
equipment.status === 'maintenance' ? 'maintenance' :
equipment.status === 'repair_needed' ? 'repair' : 'inactive';
const tooltip = document.createElement('div');
tooltip.className = 'equipment-tooltip';
tooltip.innerHTML = `
<div class="tooltip-header">
<strong>${equipment.equipment_name}</strong>
<span class="tooltip-status ${statusClass}">${statusText}</span>
</div>
<div class="tooltip-body">
<p><span class="label">코드:</span> ${equipment.equipment_code}</p>
${equipment.equipment_type ? `<p><span class="label">유형:</span> ${equipment.equipment_type}</p>` : ''}
${equipment.model_name ? `<p><span class="label">모델:</span> ${equipment.model_name}</p>` : ''}
${equipment.manufacturer ? `<p><span class="label">제조사:</span> ${equipment.manufacturer}</p>` : ''}
</div>
<div class="tooltip-actions">
<button class="tooltip-detail-btn" onclick="this.closest('.equipment-tooltip').remove(); openEquipmentPanel(${JSON.stringify(equipment).replace(/"/g, '&quot;')})">상세보기</button>
</div>
<button class="tooltip-close" onclick="this.parentElement.remove()">×</button>
`;
// 툴팁 위치 설정
const container = document.getElementById('detailMapContainer');
const rect = container.getBoundingClientRect();
tooltip.style.left = `${event.clientX - rect.left + 10}px`;
tooltip.style.top = `${event.clientY - rect.top + 10}px`;
container.appendChild(tooltip);
// 외부 클릭 시 툴팁 닫기
setTimeout(() => {
document.addEventListener('click', function closeTooltip(e) {
if (!tooltip.contains(e.target)) {
tooltip.remove();
document.removeEventListener('click', closeTooltip);
}
});
}, 100);
}
// 탭 전환
function switchWorkplaceTab(tabName) {
// 모든 탭 비활성화
document.querySelectorAll('.workplace-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.workplace-tab-content').forEach(content => {
content.classList.remove('active');
});
// 선택한 탭 활성화
document.querySelector(`.workplace-tab[data-tab="${tabName}"]`).classList.add('active');
document.getElementById(`tab-${tabName}`).classList.add('active');
}
// 순회점검 페이지로 이동
function openPatrolPage() {
closeWorkplaceModal();
window.location.href = `/pages/inspection/daily-patrol.html?category=${selectedCategory.category_id}`;
}
function closeWorkplaceModal() {
document.getElementById('workplaceDetailModal').style.display = 'none';
currentModalWorkplace = null;
}
// ==================== 새로고침 ====================
async function refreshMapData() {
if (!selectedCategory) return;
await loadTodayData();
renderMap();
}
// 전역 함수로 노출
window.closeWorkplaceModal = closeWorkplaceModal;
window.switchWorkplaceTab = switchWorkplaceTab;
window.openPatrolPage = openPatrolPage;
window.showEquipmentTooltip = showEquipmentTooltip;
// ==========================================
// 설비 상세 슬라이드 패널
// ==========================================
let currentPanelEquipment = null;
let panelFactories = [];
let panelWorkplaces = [];
let panelMovePosition = null;
let panelRepairCategories = [];
let panelRepairPhotoBases = [];
const STATUS_LABELS = {
active: '정상 가동',
maintenance: '점검 중',
repair_needed: '수리 필요',
inactive: '비활성',
external: '외부 반출',
repair_external: '수리 외주'
};
// 패널 열기
async function openEquipmentPanel(equipment) {
currentPanelEquipment = equipment;
// 패널 헤더 설정
document.getElementById('panelEquipmentTitle').textContent =
`[${equipment.equipment_code}] ${equipment.equipment_name}`;
const statusEl = document.getElementById('panelEquipmentStatus');
statusEl.textContent = STATUS_LABELS[equipment.status] || equipment.status;
statusEl.className = `slide-panel-status ${equipment.status}`;
// 기본 정보 렌더링
renderPanelInfo(equipment);
// 패널 열기
document.getElementById('equipmentSlidePanel').classList.add('open');
// 데이터 로드
await Promise.all([
loadPanelPhotos(),
loadPanelRepairHistory(),
loadPanelExternalLogs(),
loadPanelFactories(),
loadPanelRepairCategories()
]);
}
// 패널 닫기
function closeEquipmentPanel() {
document.getElementById('equipmentSlidePanel').classList.remove('open');
currentPanelEquipment = null;
}
// 기본 정보 렌더링
function renderPanelInfo(eq) {
document.getElementById('panelEquipmentInfo').innerHTML = `
<div class="panel-info-item">
<span class="label">모델명</span>
<span class="value">${eq.model_name || '-'}</span>
</div>
<div class="panel-info-item">
<span class="label">규격</span>
<span class="value">${eq.specifications || '-'}</span>
</div>
<div class="panel-info-item">
<span class="label">제조사</span>
<span class="value">${eq.manufacturer || '-'}</span>
</div>
<div class="panel-info-item">
<span class="label">구입처</span>
<span class="value">${eq.supplier || '-'}</span>
</div>
<div class="panel-info-item">
<span class="label">설비유형</span>
<span class="value">${eq.equipment_type || '-'}</span>
</div>
<div class="panel-info-item">
<span class="label">구입일</span>
<span class="value">${eq.installation_date ? formatPanelDate(eq.installation_date) : '-'}</span>
</div>
`;
}
// 사진 로드
async function loadPanelPhotos() {
if (!currentPanelEquipment) return;
try {
const response = await window.apiCall(`/equipments/${currentPanelEquipment.equipment_id}/photos`, 'GET');
if (response && response.success) {
renderPanelPhotos(response.data);
}
} catch (error) {
console.error('사진 로드 실패:', error);
document.getElementById('panelPhotoGrid').innerHTML =
'<div class="panel-empty">사진을 불러올 수 없습니다</div>';
}
}
function renderPanelPhotos(photos) {
const grid = document.getElementById('panelPhotoGrid');
if (!photos || photos.length === 0) {
grid.innerHTML = '<div class="panel-empty">등록된 사진이 없습니다</div>';
return;
}
grid.innerHTML = photos.map(photo => `
<div class="panel-photo-item" onclick="viewPanelPhoto('${window.API_BASE_URL}${photo.photo_path}')">
<img src="${window.API_BASE_URL}${photo.photo_path}" alt="">
<button class="delete-btn" onclick="event.stopPropagation(); deletePanelPhoto(${photo.photo_id})">&times;</button>
</div>
`).join('');
}
// 사진 확대 보기
function viewPanelPhoto(url) {
// 간단한 이미지 뷰어
const viewer = document.createElement('div');
viewer.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.9);display:flex;align-items:center;justify-content:center;z-index:3000;cursor:pointer;';
viewer.innerHTML = `<img src="${url}" style="max-width:90%;max-height:90%;object-fit:contain;">`;
viewer.onclick = () => viewer.remove();
document.body.appendChild(viewer);
}
// 사진 업로드 모달
function openPanelPhotoUpload() {
document.getElementById('panelPhotoInput').value = '';
document.getElementById('panelPhotoDesc').value = '';
document.getElementById('panelPhotoPreview').innerHTML = '';
document.getElementById('panelPhotoModal').style.display = 'flex';
}
function closePanelPhotoModal() {
document.getElementById('panelPhotoModal').style.display = 'none';
}
function previewPanelPhoto(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = e => {
document.getElementById('panelPhotoPreview').innerHTML =
`<img src="${e.target.result}">`;
};
reader.readAsDataURL(file);
}
}
async function uploadPanelPhoto() {
const fileInput = document.getElementById('panelPhotoInput');
const description = document.getElementById('panelPhotoDesc').value;
if (!fileInput.files[0]) {
alert('사진을 선택하세요.');
return;
}
const reader = new FileReader();
reader.onload = async e => {
try {
const response = await window.apiCall(`/equipments/${currentPanelEquipment.equipment_id}/photos`, 'POST', {
photo_base64: e.target.result,
description: description
});
if (response && response.success) {
closePanelPhotoModal();
loadPanelPhotos();
}
} catch (error) {
console.error('업로드 실패:', error);
alert('사진 업로드에 실패했습니다.');
}
};
reader.readAsDataURL(fileInput.files[0]);
}
async function deletePanelPhoto(photoId) {
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
try {
await window.apiCall(`/equipments/photos/${photoId}`, 'DELETE');
loadPanelPhotos();
} catch (error) {
console.error('삭제 실패:', error);
alert('삭제에 실패했습니다.');
}
}
// 수리 이력 로드
async function loadPanelRepairHistory() {
if (!currentPanelEquipment) return;
try {
const response = await window.apiCall(`/equipments/${currentPanelEquipment.equipment_id}/repair-history`, 'GET');
if (response && response.success) {
renderPanelRepairHistory(response.data);
}
} catch (error) {
console.error('수리 이력 로드 실패:', error);
}
}
function renderPanelRepairHistory(history) {
const container = document.getElementById('panelRepairHistory');
if (!history || history.length === 0) {
container.innerHTML = '<div class="panel-empty">수리 이력이 없습니다</div>';
return;
}
container.innerHTML = history.slice(0, 5).map(h => {
const statusLabels = {
reported: '신고됨', received: '접수', in_progress: '처리중', completed: '완료', closed: '종료'
};
const statusLabel = statusLabels[h.status] || h.status;
const statusClass = (h.status === 'completed' || h.status === 'closed') ? 'completed' : 'pending';
return `
<div class="panel-history-item">
<span class="panel-history-date">${formatPanelDate(h.created_at)}</span>
<div class="panel-history-content">
<div class="panel-history-title">${h.item_name || '수리 요청'}</div>
<div class="panel-history-detail">${truncateText(h.description, 30)}</div>
</div>
<span class="panel-history-badge ${statusClass}">${statusLabel}</span>
</div>
`;
}).join('');
}
// 외부반출 이력 로드
async function loadPanelExternalLogs() {
if (!currentPanelEquipment) return;
try {
const response = await window.apiCall(`/equipments/${currentPanelEquipment.equipment_id}/external-logs`, 'GET');
if (response && response.success) {
renderPanelExternalLogs(response.data);
}
} catch (error) {
console.error('외부반출 이력 로드 실패:', error);
}
}
function renderPanelExternalLogs(logs) {
const container = document.getElementById('panelExternalHistory');
if (!logs || logs.length === 0) {
container.innerHTML = '<div class="panel-empty">외부반출 이력이 없습니다</div>';
return;
}
container.innerHTML = logs.slice(0, 5).map(log => {
const isReturned = !!log.actual_return_date;
const statusClass = isReturned ? 'returned' : 'exported';
const statusLabel = isReturned ? '반입완료' : '반출중';
return `
<div class="panel-history-item">
<span class="panel-history-date">${formatPanelDate(log.export_date)}</span>
<div class="panel-history-content">
<div class="panel-history-title">${log.destination || '외부'}</div>
<div class="panel-history-detail">${truncateText(log.reason, 30)}</div>
</div>
${!isReturned
? `<button class="panel-history-action" onclick="openPanelReturnModal(${log.log_id})">반입</button>`
: `<span class="panel-history-badge ${statusClass}">${statusLabel}</span>`
}
</div>
`;
}).join('');
}
// 공장/작업장 로드 (이동용)
async function loadPanelFactories() {
try {
const response = await window.apiCall('/workplaces/categories', 'GET');
if (response && response.success) {
panelFactories = response.data;
}
} catch (error) {
console.error('공장 목록 로드 실패:', error);
}
}
// 수리 카테고리 로드
async function loadPanelRepairCategories() {
try {
const response = await window.apiCall('/equipments/repair-categories', 'GET');
if (response && response.success) {
panelRepairCategories = response.data;
}
} catch (error) {
console.error('수리 항목 로드 실패:', error);
}
}
// ==================== 임시이동 ====================
let selectedMoveFactory = null;
let selectedMoveWorkplace = null;
function openPanelMoveModal() {
// 초기화
selectedMoveFactory = null;
selectedMoveWorkplace = null;
panelMovePosition = null;
document.getElementById('panelMoveReason').value = '';
document.getElementById('panelMoveConfirmBtn').disabled = true;
// Step 1 표시
document.getElementById('moveStep1').style.display = 'block';
document.getElementById('moveStep2').style.display = 'none';
document.getElementById('moveStep3').style.display = 'none';
// 공장 카드 렌더링
renderMoveFactoryGrid();
document.getElementById('panelMoveModal').style.display = 'flex';
}
function closePanelMoveModal() {
document.getElementById('panelMoveModal').style.display = 'none';
}
function renderMoveFactoryGrid() {
const grid = document.getElementById('moveFactoryGrid');
const icons = ['🏭', '🏢', '🏗️', '🏛️', '⚙️'];
grid.innerHTML = panelFactories.map((f, i) => `
<div class="move-factory-card" data-category-id="${f.category_id}">
<div class="factory-icon">${icons[i % icons.length]}</div>
<div class="factory-name">${f.category_name}</div>
</div>
`).join('');
// 이벤트 리스너 추가
grid.querySelectorAll('.move-factory-card').forEach(card => {
card.onclick = () => {
const catId = card.dataset.categoryId;
const factory = panelFactories.find(f => f.category_id == catId);
if (factory) {
selectMoveFactory(factory);
}
};
});
}
let panelMapRegions = []; // 이동 모달용 지도 영역
async function selectMoveFactory(factory) {
selectedMoveFactory = {
category_id: factory.category_id,
category_name: factory.category_name,
layout_image: factory.layout_image
};
// 작업장 목록 + 지도 영역 로드
try {
const [wpResponse, regionsResponse] = await Promise.all([
window.apiCall(`/workplaces?category_id=${factory.category_id}`, 'GET'),
window.apiCall(`/workplaces/categories/${factory.category_id}/map-regions`, 'GET')
]);
if (wpResponse && wpResponse.success) {
panelWorkplaces = wpResponse.data;
}
if (regionsResponse && regionsResponse.success) {
panelMapRegions = regionsResponse.data;
console.log('[이동모달] 로드된 영역:', panelMapRegions);
} else {
console.log('[이동모달] 영역 로드 실패:', regionsResponse);
}
} catch (error) {
console.error('작업장 로드 실패:', error);
return;
}
// Step 2로 이동
document.getElementById('moveStep1').style.display = 'none';
document.getElementById('moveStep2').style.display = 'block';
document.getElementById('moveStep2Title').textContent = `${factory.category_name} - 작업장 선택`;
// 레이아웃 지도 렌더링
renderMoveLayoutMap(factory.layout_image);
}
function renderMoveLayoutMap(layoutImage) {
const container = document.getElementById('moveLayoutMapContainer');
const baseUrl = (window.API_BASE_URL || '').replace('/api', '');
console.log('[이동모달] 렌더링 - layoutImage:', layoutImage);
console.log('[이동모달] 렌더링 - panelMapRegions:', panelMapRegions);
if (!layoutImage) {
container.innerHTML = `<div style="padding: 20px; text-align: center; color: #666;">레이아웃 지도가 없습니다.</div>`;
return;
}
// 지도 영역 데이터로 클릭 가능한 영역 생성 (x_start, y_start, x_end, y_end 사용)
let regionsHtml = panelMapRegions.map(region => {
const left = region.x_start;
const top = region.y_start;
const width = region.x_end - region.x_start;
const height = region.y_end - region.y_start;
return `
<div class="layout-region move-wp-region" data-wp-id="${region.workplace_id}"
style="left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;">
<span class="region-label">${region.workplace_name}</span>
</div>
`;
}).join('');
container.innerHTML = `
<img src="${baseUrl}${layoutImage}" alt="공장 레이아웃">
${regionsHtml}
`;
// 영역 클릭 이벤트
container.querySelectorAll('.move-wp-region').forEach(region => {
region.onclick = () => {
const wpId = parseInt(region.dataset.wpId);
const wp = panelWorkplaces.find(w => w.workplace_id === wpId);
if (wp) selectMoveWorkplace(wp);
};
});
}
function selectMoveWorkplace(workplace) {
if (!workplace) return;
console.log('[이동모달] 선택된 작업장:', workplace);
console.log('[이동모달] layout_image:', workplace.layout_image);
selectedMoveWorkplace = workplace;
// 상세 지도가 없으면 위치 없이 바로 확인 가능하게
if (!workplace.layout_image) {
panelMovePosition = null; // 위치 좌표 없음
document.getElementById('moveStep2').style.display = 'none';
document.getElementById('moveStep3').style.display = 'block';
document.getElementById('moveStep3Title').textContent = `${workplace.workplace_name}`;
document.getElementById('moveDetailMapContainer').innerHTML = `
<div style="padding: 40px; text-align: center; color: #666;">
<div style="font-size: 3rem; margin-bottom: 12px;">📍</div>
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 8px;">${workplace.workplace_name}</div>
<div>이 작업장에는 상세 지도가 없습니다.</div>
<div style="margin-top: 8px; color: #999; font-size: 0.9rem;">위치 좌표 없이 작업장만 기록됩니다.</div>
</div>
`;
document.getElementById('panelMoveConfirmBtn').disabled = false;
return;
}
// Step 3으로 이동
document.getElementById('moveStep2').style.display = 'none';
document.getElementById('moveStep3').style.display = 'block';
document.getElementById('moveStep3Title').textContent = `${workplace.workplace_name} - 위치 선택`;
// 상세 지도 렌더링
renderMoveDetailMap();
}
async function renderMoveDetailMap() {
const container = document.getElementById('moveDetailMapContainer');
const baseUrl = (window.API_BASE_URL || '').replace('/api', '');
const imageUrl = `${baseUrl}${selectedMoveWorkplace.layout_image}`;
// 이동 대상 작업장의 기존 설비 로드
let existingEquipments = [];
try {
const response = await window.apiCall(`/equipments?workplace_id=${selectedMoveWorkplace.workplace_id}`, 'GET');
if (response && response.success) {
existingEquipments = response.data || [];
}
} catch (error) {
console.error('설비 로드 실패:', error);
}
// 이미지 로드 후 마커 배치
const img = new Image();
img.onload = () => {
// 기존 설비 마커 HTML 생성
const markersHtml = existingEquipments.map(eq => {
const isCurrentEquipment = eq.equipment_id === currentPanelEquipment.equipment_id;
const markerClass = isCurrentEquipment ? 'move-eq-marker current-moving' : 'move-eq-marker existing';
const width = eq.map_width_percent || 6;
const height = eq.map_height_percent || 4;
return `
<div class="${markerClass}"
style="left: ${eq.map_x_percent}%; top: ${eq.map_y_percent}%;
width: ${width}%; height: ${height}%;"
title="${eq.equipment_name}">
<span class="eq-label">${eq.equipment_code || eq.equipment_name}</span>
</div>
`;
}).join('');
container.innerHTML = `
<div class="move-map-wrapper" id="moveMapWrapper">
<img src="${imageUrl}" id="moveDetailMapImg">
<div class="move-markers-layer">
${markersHtml}
<div id="moveTargetMarker" class="move-eq-marker target" style="display: none;"></div>
</div>
</div>
`;
// 지도 클릭 이벤트
document.getElementById('moveMapWrapper').addEventListener('click', onMoveDetailMapClick);
};
img.onerror = () => {
container.innerHTML = `<div style="padding: 40px; text-align: center; color: #666;">지도를 불러올 수 없습니다.</div>`;
};
img.src = imageUrl;
}
function onMoveDetailMapClick(event) {
const wrapper = document.getElementById('moveMapWrapper');
const rect = wrapper.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
// 현재 설비의 크기 사용 (없으면 기본값)
const width = currentPanelEquipment.map_width_percent || 6;
const height = currentPanelEquipment.map_height_percent || 4;
panelMovePosition = { x, y, width, height };
// 타겟 마커 표시
const targetMarker = document.getElementById('moveTargetMarker');
targetMarker.style.left = x + '%';
targetMarker.style.top = y + '%';
targetMarker.style.width = width + '%';
targetMarker.style.height = height + '%';
targetMarker.style.display = 'flex';
targetMarker.innerHTML = `<span class="eq-label">${currentPanelEquipment.equipment_code || currentPanelEquipment.equipment_name}</span>`;
document.getElementById('panelMoveConfirmBtn').disabled = false;
}
function moveBackToStep1() {
document.getElementById('moveStep2').style.display = 'none';
document.getElementById('moveStep1').style.display = 'block';
selectedMoveFactory = null;
}
function moveBackToStep2() {
document.getElementById('moveStep3').style.display = 'none';
document.getElementById('moveStep2').style.display = 'block';
selectedMoveWorkplace = null;
panelMovePosition = null;
document.getElementById('panelMoveConfirmBtn').disabled = true;
}
async function confirmPanelMove() {
if (!selectedMoveWorkplace) {
alert('작업장을 선택하세요.');
return;
}
// 상세 지도가 있는데 위치를 선택 안 한 경우
if (selectedMoveWorkplace.layout_image && !panelMovePosition) {
alert('지도에서 위치를 클릭하세요.');
return;
}
const reason = document.getElementById('panelMoveReason').value;
try {
const response = await window.apiCall(`/equipments/${currentPanelEquipment.equipment_id}/move`, 'POST', {
target_workplace_id: selectedMoveWorkplace.workplace_id,
target_x_percent: panelMovePosition ? panelMovePosition.x.toFixed(2) : null,
target_y_percent: panelMovePosition ? panelMovePosition.y.toFixed(2) : null,
from_workplace_id: currentPanelEquipment.workplace_id,
from_x_percent: currentPanelEquipment.map_x_percent,
from_y_percent: currentPanelEquipment.map_y_percent,
reason: reason
});
if (response && response.success) {
closePanelMoveModal();
closeEquipmentPanel();
alert('설비가 임시 이동되었습니다.');
// 설비 마커 새로고침
if (currentModalWorkplace && currentModalWorkplace.workplace_id) {
loadEquipmentMarkers(currentModalWorkplace.workplace_id);
}
}
} catch (error) {
console.error('이동 실패:', error);
alert('이동에 실패했습니다.');
}
}
// ==================== 수리신청 ====================
function openPanelRepairModal() {
const select = document.getElementById('panelRepairItem');
select.innerHTML = '<option value="">선택하세요</option>';
panelRepairCategories.forEach(item => {
select.innerHTML += `<option value="${item.item_id}">${item.item_name}</option>`;
});
document.getElementById('panelRepairDesc').value = '';
document.getElementById('panelRepairPhotoInput').value = '';
panelRepairPhotoBases = [];
document.getElementById('panelRepairModal').style.display = 'flex';
}
function closePanelRepairModal() {
document.getElementById('panelRepairModal').style.display = 'none';
}
async function submitPanelRepair() {
const itemId = document.getElementById('panelRepairItem').value;
const description = document.getElementById('panelRepairDesc').value;
if (!description) {
alert('수리 내용을 입력하세요.');
return;
}
// 사진 처리
const fileInput = document.getElementById('panelRepairPhotoInput');
const photos = [];
if (fileInput.files.length > 0) {
for (const file of fileInput.files) {
const base64 = await readFileAsBase64(file);
photos.push(base64);
}
}
try {
const response = await window.apiCall(`/equipments/${currentPanelEquipment.equipment_id}/repair-request`, 'POST', {
item_id: itemId || null,
description: description,
photo_base64_list: photos,
workplace_id: currentPanelEquipment.workplace_id
});
if (response && response.success) {
closePanelRepairModal();
loadPanelRepairHistory();
alert('수리 신청이 접수되었습니다.');
}
} catch (error) {
console.error('수리 신청 실패:', error);
alert('수리 신청에 실패했습니다.');
}
}
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ==================== 외부반출 ====================
function openPanelExportModal() {
document.getElementById('panelIsRepairExport').checked = false;
document.getElementById('panelExportDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('panelExpectedReturn').value = '';
document.getElementById('panelExportDest').value = '';
document.getElementById('panelExportReason').value = '';
document.getElementById('panelExportModal').style.display = 'flex';
}
function closePanelExportModal() {
document.getElementById('panelExportModal').style.display = 'none';
}
async function submitPanelExport() {
const exportDate = document.getElementById('panelExportDate').value;
if (!exportDate) {
alert('반출일을 입력하세요.');
return;
}
try {
const response = await window.apiCall(`/equipments/${currentPanelEquipment.equipment_id}/export`, 'POST', {
export_date: exportDate,
expected_return_date: document.getElementById('panelExpectedReturn').value || null,
destination: document.getElementById('panelExportDest').value,
reason: document.getElementById('panelExportReason').value,
is_repair: document.getElementById('panelIsRepairExport').checked
});
if (response && response.success) {
closePanelExportModal();
loadPanelExternalLogs();
alert('외부 반출이 등록되었습니다.');
}
} catch (error) {
console.error('반출 실패:', error);
alert('반출 등록에 실패했습니다.');
}
}
// ==================== 반입 ====================
function openPanelReturnModal(logId) {
document.getElementById('panelReturnLogId').value = logId;
document.getElementById('panelReturnDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('panelReturnStatus').value = 'active';
document.getElementById('panelReturnModal').style.display = 'flex';
}
function closePanelReturnModal() {
document.getElementById('panelReturnModal').style.display = 'none';
}
async function submitPanelReturn() {
const logId = document.getElementById('panelReturnLogId').value;
const returnDate = document.getElementById('panelReturnDate').value;
if (!returnDate) {
alert('반입일을 입력하세요.');
return;
}
try {
const response = await window.apiCall(`/equipments/external-logs/${logId}/return`, 'POST', {
return_date: returnDate,
new_status: document.getElementById('panelReturnStatus').value
});
if (response && response.success) {
closePanelReturnModal();
loadPanelExternalLogs();
alert('반입 처리가 완료되었습니다.');
}
} catch (error) {
console.error('반입 실패:', error);
alert('반입 처리에 실패했습니다.');
}
}
// ==================== 유틸리티 ====================
function formatPanelDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return `${date.getMonth() + 1}/${date.getDate()}`;
}
function truncateText(text, maxLen) {
if (!text) return '-';
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
}
// 전역 함수 노출
window.openEquipmentPanel = openEquipmentPanel;
window.closeEquipmentPanel = closeEquipmentPanel;
window.openPanelPhotoUpload = openPanelPhotoUpload;
window.closePanelPhotoModal = closePanelPhotoModal;
window.previewPanelPhoto = previewPanelPhoto;
window.uploadPanelPhoto = uploadPanelPhoto;
window.deletePanelPhoto = deletePanelPhoto;
window.viewPanelPhoto = viewPanelPhoto;
window.openPanelMoveModal = openPanelMoveModal;
window.closePanelMoveModal = closePanelMoveModal;
window.selectMoveFactory = selectMoveFactory;
window.selectMoveWorkplace = selectMoveWorkplace;
window.onMoveDetailMapClick = onMoveDetailMapClick;
window.moveBackToStep1 = moveBackToStep1;
window.moveBackToStep2 = moveBackToStep2;
window.confirmPanelMove = confirmPanelMove;
window.openPanelRepairModal = openPanelRepairModal;
window.closePanelRepairModal = closePanelRepairModal;
window.submitPanelRepair = submitPanelRepair;
window.openPanelExportModal = openPanelExportModal;
window.closePanelExportModal = closePanelExportModal;
window.submitPanelExport = submitPanelExport;
window.openPanelReturnModal = openPanelReturnModal;
window.closePanelReturnModal = closePanelReturnModal;
window.submitPanelReturn = submitPanelReturn;