- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1424 lines
46 KiB
JavaScript
1424 lines
46 KiB
JavaScript
// zone-detail.js - 구역 상세 페이지 JavaScript
|
||
|
||
// 전역 상태
|
||
let workplaceId = null;
|
||
let workplaceData = null;
|
||
let zoneItems = [];
|
||
let selectedZoneItem = null;
|
||
let isAddingItem = false;
|
||
let selectionStart = null;
|
||
let selectionBox = null;
|
||
|
||
// XSS 방지를 위한 HTML 이스케이프 함수
|
||
function escapeHtml(str) {
|
||
if (str === null || str === undefined) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// axios 설정 대기
|
||
function waitForAxiosConfig() {
|
||
return new Promise((resolve) => {
|
||
const check = setInterval(() => {
|
||
if (axios.defaults.baseURL) {
|
||
clearInterval(check);
|
||
resolve();
|
||
}
|
||
}, 50);
|
||
setTimeout(() => {
|
||
clearInterval(check);
|
||
resolve();
|
||
}, 5000);
|
||
});
|
||
}
|
||
|
||
// 페이지 초기화
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
await waitForAxiosConfig();
|
||
|
||
// URL에서 workplace_id 파라미터 가져오기
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
workplaceId = urlParams.get('id');
|
||
|
||
if (!workplaceId) {
|
||
alert('잘못된 접근입니다.');
|
||
goBack();
|
||
return;
|
||
}
|
||
|
||
// 현재 날짜 표시
|
||
const now = new Date();
|
||
document.getElementById('currentDate').textContent = now.toLocaleDateString('ko-KR', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
weekday: 'short'
|
||
});
|
||
|
||
// 데이터 로드
|
||
await loadWorkplaceDetail();
|
||
});
|
||
|
||
// 작업장 상세 정보 로드
|
||
async function loadWorkplaceDetail() {
|
||
try {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const response = await axios.get(`/patrol/workplaces/${workplaceId}/detail?date=${today}`);
|
||
|
||
if (response.data.success) {
|
||
workplaceData = response.data.data;
|
||
renderPage();
|
||
} else {
|
||
throw new Error(response.data.message || '데이터 로드 실패');
|
||
}
|
||
} catch (error) {
|
||
console.error('작업장 상세 정보 로드 실패:', error);
|
||
alert('데이터를 불러오는데 실패했습니다.');
|
||
}
|
||
}
|
||
|
||
// 페이지 렌더링
|
||
function renderPage() {
|
||
if (!workplaceData) return;
|
||
|
||
const { workplace, summary } = workplaceData;
|
||
|
||
// 헤더 정보
|
||
document.getElementById('zoneName').textContent = workplace.workplace_name;
|
||
document.getElementById('zoneCategory').textContent = workplace.category_name || '';
|
||
|
||
// 요약 카드
|
||
renderSummaryCards(summary);
|
||
|
||
// 각 탭 콘텐츠 렌더링
|
||
renderMapTab();
|
||
renderIssuesTab();
|
||
renderEquipmentTab();
|
||
renderVisitsTab();
|
||
renderTbmTab();
|
||
renderPatrolTab();
|
||
|
||
// 구역 현황 로드
|
||
loadZoneItems();
|
||
}
|
||
|
||
// ==================== 구역 지도 탭 ====================
|
||
|
||
// 지도 탭 렌더링
|
||
function renderMapTab() {
|
||
const container = document.getElementById('zoneMapContainer');
|
||
const { workplace } = workplaceData;
|
||
|
||
// 작업장 자체의 지도 이미지 사용
|
||
const mapImage = workplace.layout_image;
|
||
if (mapImage) {
|
||
// 이미지 URL 생성 (API base URL에서 /api 제거)
|
||
const staticUrl = window.API_BASE_URL ? window.API_BASE_URL.replace(/\/api$/, '') : '';
|
||
const imageUrl = mapImage.startsWith('http') ? mapImage : staticUrl + mapImage;
|
||
|
||
container.innerHTML = `
|
||
<img src="${escapeHtml(imageUrl)}"
|
||
alt="${escapeHtml(workplace.workplace_name)} 지도"
|
||
class="zone-map-image"
|
||
onload="onMapImageLoaded()"
|
||
onerror="onMapImageError()">
|
||
`;
|
||
} else {
|
||
container.innerHTML = `
|
||
<div class="map-placeholder">
|
||
<div class="map-placeholder-icon">🗺️</div>
|
||
<p>작업장 지도 이미지가 등록되지 않았습니다.</p>
|
||
<p style="font-size: 0.8rem; color: #94a3b8;">지도 없이도 현황을 등록할 수 있습니다.</p>
|
||
</div>
|
||
`;
|
||
// 지도가 없어도 설비 목록은 표시
|
||
renderEquipmentList();
|
||
}
|
||
|
||
// 마우스 이벤트 핸들러 설정
|
||
setupMapEventHandlers(container);
|
||
}
|
||
|
||
// 설비 목록 렌더링 (지도가 없을 때 텍스트로 표시)
|
||
function renderEquipmentList() {
|
||
if (!workplaceData || !workplaceData.equipments || workplaceData.equipments.length === 0) return;
|
||
|
||
const container = document.getElementById('zoneMapContainer');
|
||
const { equipments } = workplaceData;
|
||
|
||
const listHtml = `
|
||
<div style="margin-top: 1rem; padding: 1rem; background: #f8fafc; border-radius: 8px;">
|
||
<h4 style="margin: 0 0 0.75rem; font-size: 0.9rem; color: #1e293b;">등록된 설비 (${equipments.length}개)</h4>
|
||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||
${equipments.map(eq => {
|
||
let statusIcon = '⚙️';
|
||
let statusText = '정상';
|
||
let statusColor = '#22c55e';
|
||
if (eq.status === 'repair_needed' || eq.status === 'repair_external') {
|
||
statusIcon = '🔧';
|
||
statusText = eq.status === 'repair_external' ? '외부수리' : '수리필요';
|
||
statusColor = '#ef4444';
|
||
} else if (eq.status === 'maintenance') {
|
||
statusIcon = '⚠️';
|
||
statusText = '점검중';
|
||
statusColor = '#f59e0b';
|
||
} else if (eq.is_temporarily_moved) {
|
||
statusIcon = '📤';
|
||
statusText = '이동됨';
|
||
statusColor = '#9ca3af';
|
||
}
|
||
return `
|
||
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; background: #fff; border-radius: 6px; border-left: 3px solid ${statusColor};">
|
||
<span>${statusIcon}</span>
|
||
<span style="flex: 1; font-size: 0.85rem;">${escapeHtml(eq.equipment_name)}</span>
|
||
<span style="font-size: 0.75rem; color: ${statusColor};">${statusText}</span>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
container.insertAdjacentHTML('beforeend', listHtml);
|
||
}
|
||
|
||
// 지도 이미지 로드 완료
|
||
function onMapImageLoaded() {
|
||
renderEquipmentsOnMap();
|
||
renderZoneItemsOnMap();
|
||
}
|
||
|
||
// 지도 이미지 로드 실패
|
||
function onMapImageError() {
|
||
const container = document.getElementById('zoneMapContainer');
|
||
container.innerHTML = `
|
||
<div class="map-placeholder">
|
||
<div class="map-placeholder-icon">⚠️</div>
|
||
<p>지도 이미지를 불러올 수 없습니다.</p>
|
||
<p style="font-size: 0.8rem; color: #94a3b8;">지도 없이도 현황을 등록할 수 있습니다.</p>
|
||
</div>
|
||
`;
|
||
setupMapEventHandlers(container);
|
||
}
|
||
|
||
// 지도 이벤트 핸들러 설정
|
||
function setupMapEventHandlers(container) {
|
||
container.addEventListener('mousedown', onMapMouseDown);
|
||
container.addEventListener('mousemove', onMapMouseMove);
|
||
container.addEventListener('mouseup', onMapMouseUp);
|
||
container.addEventListener('mouseleave', onMapMouseLeave);
|
||
|
||
// 터치 이벤트 (모바일)
|
||
container.addEventListener('touchstart', onMapTouchStart, { passive: false });
|
||
container.addEventListener('touchmove', onMapTouchMove, { passive: false });
|
||
container.addEventListener('touchend', onMapTouchEnd);
|
||
}
|
||
|
||
// 설비를 지도에 렌더링
|
||
function renderEquipmentsOnMap() {
|
||
const container = document.getElementById('zoneMapContainer');
|
||
if (!workplaceData || !workplaceData.equipments) return;
|
||
|
||
const { equipments } = workplaceData;
|
||
const currentWorkplaceId = parseInt(workplaceId);
|
||
|
||
// 기존 설비 마커 제거
|
||
container.querySelectorAll('.equipment-map-marker').forEach(el => el.remove());
|
||
|
||
equipments.forEach(eq => {
|
||
// 이 작업장에 표시할 좌표 결정
|
||
let x, y, width, height;
|
||
let isHere = true; // 이 작업장에 현재 있는지
|
||
let isVisitor = false; // 다른 곳에서 임시로 온 설비인지
|
||
|
||
// 원래 이 작업장 설비인 경우
|
||
if (eq.workplace_id === currentWorkplaceId) {
|
||
if (eq.is_temporarily_moved && eq.current_workplace_id !== currentWorkplaceId) {
|
||
// 다른 곳으로 이동함 - 원래 위치에 "이동됨" 표시
|
||
x = eq.map_x_percent;
|
||
y = eq.map_y_percent;
|
||
width = eq.map_width_percent || 5;
|
||
height = eq.map_height_percent || 5;
|
||
isHere = false;
|
||
} else {
|
||
// 현재 여기 있음
|
||
x = eq.current_map_x_percent || eq.map_x_percent;
|
||
y = eq.current_map_y_percent || eq.map_y_percent;
|
||
width = eq.current_map_width_percent || eq.map_width_percent || 5;
|
||
height = eq.current_map_height_percent || eq.map_height_percent || 5;
|
||
}
|
||
} else if (eq.current_workplace_id === currentWorkplaceId) {
|
||
// 다른 곳에서 임시로 온 설비
|
||
x = eq.current_map_x_percent;
|
||
y = eq.current_map_y_percent;
|
||
width = eq.current_map_width_percent || 5;
|
||
height = eq.current_map_height_percent || 5;
|
||
isVisitor = true;
|
||
} else {
|
||
return; // 이 작업장과 관계없는 설비
|
||
}
|
||
|
||
// 좌표가 없으면 건너뜀
|
||
if (x === null || x === undefined || y === null || y === undefined) return;
|
||
|
||
// 상태에 따른 색상과 아이콘
|
||
let bgColor, borderColor, icon, statusText;
|
||
if (!isHere) {
|
||
// 다른 곳으로 이동됨
|
||
bgColor = 'rgba(156, 163, 175, 0.3)';
|
||
borderColor = '#9ca3af';
|
||
icon = '📤';
|
||
statusText = `→ ${eq.current_workplace_name || '외부'}`;
|
||
} else if (eq.status === 'repair_needed' || eq.status === 'repair_external') {
|
||
// 수리 필요/요청
|
||
bgColor = 'rgba(239, 68, 68, 0.4)';
|
||
borderColor = '#ef4444';
|
||
icon = '🔧';
|
||
statusText = eq.status === 'repair_external' ? '외부수리중' : '수리필요';
|
||
} else if (eq.status === 'maintenance') {
|
||
// 점검중
|
||
bgColor = 'rgba(245, 158, 11, 0.4)';
|
||
borderColor = '#f59e0b';
|
||
icon = '⚠️';
|
||
statusText = '점검중';
|
||
} else if (isVisitor) {
|
||
// 다른 곳에서 온 임시 설비
|
||
bgColor = 'rgba(139, 92, 246, 0.4)';
|
||
borderColor = '#8b5cf6';
|
||
icon = '📥';
|
||
statusText = '임시배치';
|
||
} else {
|
||
// 정상
|
||
bgColor = 'rgba(34, 197, 94, 0.4)';
|
||
borderColor = '#22c55e';
|
||
icon = '⚙️';
|
||
statusText = '정상';
|
||
}
|
||
|
||
// 상태별 마커 클래스 결정
|
||
let markerClass = 'equipment-marker';
|
||
if (!isHere) {
|
||
markerClass += ' inactive';
|
||
} else if (eq.status === 'repair_needed' || eq.status === 'repair_external') {
|
||
markerClass += ' repair';
|
||
} else if (eq.status === 'maintenance') {
|
||
markerClass += ' maintenance';
|
||
} else if (isVisitor) {
|
||
markerClass += ' moved';
|
||
} else {
|
||
markerClass += ' active';
|
||
}
|
||
|
||
const marker = document.createElement('div');
|
||
marker.className = markerClass;
|
||
marker.style.cssText = `
|
||
left: ${x}%;
|
||
top: ${y}%;
|
||
width: ${width}%;
|
||
height: ${height}%;
|
||
`;
|
||
|
||
// 이동/임시배치 표시 이모지
|
||
const badge = !isHere ? ' 📤' : isVisitor ? ' 🚚' : '';
|
||
|
||
// 마커 라벨 (설비 이름만 표시)
|
||
marker.innerHTML = `<span class="marker-label">${escapeHtml(eq.equipment_name)}${badge}</span>`;
|
||
marker.title = `${eq.equipment_name}\n상태: ${statusText}`;
|
||
|
||
// 호버 시 정보 표시
|
||
marker.addEventListener('mouseenter', () => {
|
||
marker.style.transform = 'scale(1.1)';
|
||
marker.style.zIndex = '50';
|
||
});
|
||
marker.addEventListener('mouseleave', () => {
|
||
marker.style.transform = 'scale(1)';
|
||
marker.style.zIndex = '5';
|
||
});
|
||
|
||
// 클릭 시 상세 정보 표시
|
||
marker.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
showEquipmentInfo(eq, isHere, isVisitor);
|
||
});
|
||
|
||
container.appendChild(marker);
|
||
});
|
||
}
|
||
|
||
// 설비 정보 표시
|
||
function showEquipmentInfo(eq, isHere, isVisitor) {
|
||
let statusText = '정상';
|
||
if (!isHere) {
|
||
statusText = `다른 작업장으로 이동됨 (${eq.current_workplace_name || '외부'})`;
|
||
} else if (eq.status === 'repair_needed') {
|
||
statusText = '수리 필요';
|
||
} else if (eq.status === 'repair_external') {
|
||
statusText = '외부 수리중';
|
||
} else if (eq.status === 'maintenance') {
|
||
statusText = '점검중';
|
||
} else if (isVisitor) {
|
||
statusText = '임시 배치 (원래 위치 아님)';
|
||
}
|
||
|
||
alert(`📌 ${eq.equipment_name}\n\n` +
|
||
`유형: ${eq.equipment_type || '-'}\n` +
|
||
`관리번호: ${eq.equipment_code || '-'}\n` +
|
||
`상태: ${statusText}\n` +
|
||
(eq.notes ? `비고: ${eq.notes}` : ''));
|
||
}
|
||
|
||
// 구역 현황 목록 로드
|
||
async function loadZoneItems() {
|
||
try {
|
||
const response = await axios.get(`/patrol/workplaces/${workplaceId}/zone-items`);
|
||
if (response.data.success) {
|
||
zoneItems = response.data.data || [];
|
||
renderZoneItemsList();
|
||
renderZoneItemsOnMap();
|
||
}
|
||
} catch (error) {
|
||
console.error('구역 현황 로드 실패:', error);
|
||
zoneItems = [];
|
||
renderZoneItemsList();
|
||
}
|
||
}
|
||
|
||
// 현황 목록 렌더링
|
||
function renderZoneItemsList() {
|
||
const container = document.getElementById('zoneItemsList');
|
||
const typeLabels = {
|
||
'working': '작업중',
|
||
'temp_storage': '임시적치',
|
||
'moved_equipment': '이동설비',
|
||
'unreported': '미신고품',
|
||
'general': '일반',
|
||
'other': '기타'
|
||
};
|
||
const warningLabels = {
|
||
'good': '양호',
|
||
'caution': '주의',
|
||
'needs_management': '관리필요'
|
||
};
|
||
const projectTypeLabels = {
|
||
'project': '프로젝트',
|
||
'non_project': '비프로젝트',
|
||
'unknown': '미확인'
|
||
};
|
||
|
||
if (!zoneItems.length) {
|
||
container.innerHTML = `
|
||
<div class="zone-items-header">
|
||
<h4>등록된 현황</h4>
|
||
<span class="zone-items-count">0개</span>
|
||
</div>
|
||
<div class="zone-items-empty">
|
||
등록된 현황이 없습니다.<br>
|
||
<span style="font-size: 0.8rem;">위 [현황 등록] 버튼을 눌러 지도에서 범위를 선택하세요.</span>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="zone-items-header">
|
||
<h4>등록된 현황</h4>
|
||
<span class="zone-items-count">${zoneItems.length}개</span>
|
||
</div>
|
||
${zoneItems.map(item => `
|
||
<div class="zone-item-row ${selectedZoneItem === item.item_id ? 'selected' : ''}"
|
||
onclick="selectZoneItem(${item.item_id})"
|
||
ondblclick="editZoneItem(${item.item_id})">
|
||
<div class="zone-item-color" style="background: ${escapeHtml(item.color || '#3b82f6')};"></div>
|
||
<div class="zone-item-info">
|
||
<div class="zone-item-name">
|
||
${escapeHtml(item.item_name)}
|
||
${item.photos && item.photos.length > 0 ? `<span style="color: #6b7280; font-size: 0.75rem;">📷${item.photos.length}</span>` : ''}
|
||
</div>
|
||
<div class="zone-item-meta">
|
||
<span>${typeLabels[item.item_type] || item.item_type}</span>
|
||
${item.project_type === 'project' && item.project_name ? `<span>• ${escapeHtml(item.project_name)}</span>` : ''}
|
||
${item.project_type === 'unknown' ? `<span style="color: #d97706;">• 미확인</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<span class="zone-item-warning ${item.warning_level || 'good'}">${warningLabels[item.warning_level] || '양호'}</span>
|
||
</div>
|
||
`).join('')}
|
||
`;
|
||
}
|
||
|
||
// 지도 위에 현황 마커 렌더링
|
||
function renderZoneItemsOnMap() {
|
||
const container = document.getElementById('zoneMapContainer');
|
||
|
||
// 기존 마커 제거
|
||
container.querySelectorAll('.zone-item-marker').forEach(el => el.remove());
|
||
|
||
// 유형 라벨
|
||
const typeLabels = {
|
||
'working': '작업중',
|
||
'temp_storage': '임시적치',
|
||
'moved_equipment': '이동설비',
|
||
'unreported': '미신고품'
|
||
};
|
||
|
||
// 마커 추가
|
||
zoneItems.forEach(item => {
|
||
const marker = document.createElement('div');
|
||
const isSelected = selectedZoneItem === item.item_id;
|
||
const warningClass = item.warning_level === 'needs_management' ? 'warning-high' :
|
||
item.warning_level === 'caution' ? 'warning-mid' : '';
|
||
|
||
marker.className = `zone-item-marker ${isSelected ? 'selected' : ''} ${warningClass}`;
|
||
marker.style.cssText = `
|
||
left: ${item.x_percent}%;
|
||
top: ${item.y_percent}%;
|
||
width: ${item.width_percent || 5}%;
|
||
height: ${item.height_percent || 5}%;
|
||
--marker-color: ${item.color || '#3b82f6'};
|
||
`;
|
||
marker.dataset.itemId = item.item_id;
|
||
|
||
// 클릭 시 바로 수정 모달 열기
|
||
marker.onclick = (e) => {
|
||
e.stopPropagation();
|
||
editZoneItem(item.item_id);
|
||
};
|
||
|
||
// 주의 수준 아이콘
|
||
const warningIcon = item.warning_level === 'needs_management' ? '⚠️ ' :
|
||
item.warning_level === 'caution' ? '⚡ ' : '';
|
||
|
||
// 라벨 (이름 + 유형)
|
||
const typeName = typeLabels[item.item_type] || item.item_type;
|
||
marker.innerHTML = `
|
||
<span class="zone-item-marker-label">${warningIcon}${escapeHtml(item.item_name)}</span>
|
||
<span class="zone-item-marker-type">${typeName}</span>
|
||
`;
|
||
|
||
marker.title = `${item.item_name}\n유형: ${typeName}\n클릭하여 수정`;
|
||
|
||
container.appendChild(marker);
|
||
});
|
||
}
|
||
|
||
// 현황 선택
|
||
function selectZoneItem(itemId) {
|
||
selectedZoneItem = selectedZoneItem === itemId ? null : itemId;
|
||
renderZoneItemsList();
|
||
renderZoneItemsOnMap();
|
||
}
|
||
|
||
// 현황 등록 시작/토글
|
||
function startAddItem() {
|
||
const container = document.getElementById('zoneMapContainer');
|
||
const btn = document.getElementById('addItemBtn');
|
||
|
||
if (isAddingItem) {
|
||
// 이미 등록 모드면 취소
|
||
cancelAddItem();
|
||
return;
|
||
}
|
||
|
||
isAddingItem = true;
|
||
selectedZoneItem = null;
|
||
container.classList.add('adding-item');
|
||
|
||
// 버튼 텍스트 변경
|
||
btn.innerHTML = '❌ 취소';
|
||
btn.classList.add('btn-danger');
|
||
btn.classList.remove('btn-primary');
|
||
|
||
// 하단 안내 바 (오버레이 대신)
|
||
let guideBar = document.getElementById('addItemGuideBar');
|
||
if (!guideBar) {
|
||
guideBar = document.createElement('div');
|
||
guideBar.id = 'addItemGuideBar';
|
||
guideBar.className = 'add-item-guide-bar';
|
||
container.appendChild(guideBar);
|
||
}
|
||
guideBar.innerHTML = `
|
||
<span class="guide-icon">👆</span>
|
||
<span class="guide-text"><strong>클릭</strong>: 위치 지정 | <strong>드래그</strong>: 영역 선택</span>
|
||
`;
|
||
guideBar.style.display = 'flex';
|
||
}
|
||
|
||
// 현황 등록 취소
|
||
function cancelAddItem() {
|
||
isAddingItem = false;
|
||
const container = document.getElementById('zoneMapContainer');
|
||
container.classList.remove('adding-item');
|
||
|
||
// 안내 바 숨기기
|
||
const guideBar = document.getElementById('addItemGuideBar');
|
||
if (guideBar) guideBar.style.display = 'none';
|
||
|
||
// 선택 박스 정리
|
||
if (selectionBox) {
|
||
selectionBox.remove();
|
||
selectionBox = null;
|
||
}
|
||
selectionStart = null;
|
||
|
||
// 버튼 원복
|
||
const btn = document.getElementById('addItemBtn');
|
||
btn.innerHTML = '➕ 현황 등록';
|
||
btn.classList.remove('btn-danger');
|
||
btn.classList.add('btn-primary');
|
||
}
|
||
|
||
// 마우스 이벤트 핸들러
|
||
function onMapMouseDown(e) {
|
||
if (!isAddingItem) return;
|
||
if (e.target.closest('.zone-item-marker, .equipment-marker')) return; // 기존 마커 클릭 무시
|
||
|
||
e.preventDefault();
|
||
const container = document.getElementById('zoneMapContainer');
|
||
const rect = container.getBoundingClientRect();
|
||
|
||
selectionStart = {
|
||
x: e.clientX - rect.left,
|
||
y: e.clientY - rect.top,
|
||
xPercent: ((e.clientX - rect.left) / rect.width) * 100,
|
||
yPercent: ((e.clientY - rect.top) / rect.height) * 100,
|
||
time: Date.now()
|
||
};
|
||
|
||
// 선택 박스 생성
|
||
selectionBox = document.createElement('div');
|
||
selectionBox.className = 'selection-box';
|
||
selectionBox.style.left = selectionStart.x + 'px';
|
||
selectionBox.style.top = selectionStart.y + 'px';
|
||
selectionBox.style.width = '0px';
|
||
selectionBox.style.height = '0px';
|
||
container.appendChild(selectionBox);
|
||
|
||
// 크기 표시 라벨
|
||
const sizeLabel = document.createElement('div');
|
||
sizeLabel.className = 'selection-size-label';
|
||
sizeLabel.id = 'selectionSizeLabel';
|
||
selectionBox.appendChild(sizeLabel);
|
||
}
|
||
|
||
function onMapMouseMove(e) {
|
||
if (!isAddingItem || !selectionStart || !selectionBox) return;
|
||
|
||
const container = document.getElementById('zoneMapContainer');
|
||
const rect = container.getBoundingClientRect();
|
||
|
||
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
|
||
|
||
const left = Math.min(selectionStart.x, currentX);
|
||
const top = Math.min(selectionStart.y, currentY);
|
||
const width = Math.abs(currentX - selectionStart.x);
|
||
const height = Math.abs(currentY - selectionStart.y);
|
||
|
||
selectionBox.style.left = left + 'px';
|
||
selectionBox.style.top = top + 'px';
|
||
selectionBox.style.width = width + 'px';
|
||
selectionBox.style.height = height + 'px';
|
||
|
||
// 크기 표시 업데이트
|
||
const widthPercent = ((width / rect.width) * 100).toFixed(1);
|
||
const heightPercent = ((height / rect.height) * 100).toFixed(1);
|
||
const sizeLabel = document.getElementById('selectionSizeLabel');
|
||
if (sizeLabel && width > 30) {
|
||
sizeLabel.textContent = `${widthPercent}% × ${heightPercent}%`;
|
||
sizeLabel.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function onMapMouseUp(e) {
|
||
if (!isAddingItem || !selectionStart) return;
|
||
|
||
const container = document.getElementById('zoneMapContainer');
|
||
const rect = container.getBoundingClientRect();
|
||
|
||
const endX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||
const endY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
|
||
|
||
const width = Math.abs(endX - selectionStart.x);
|
||
const height = Math.abs(endY - selectionStart.y);
|
||
const elapsed = Date.now() - selectionStart.time;
|
||
|
||
// 선택 박스 제거
|
||
if (selectionBox) {
|
||
selectionBox.remove();
|
||
selectionBox = null;
|
||
}
|
||
|
||
// 빠른 클릭 (200ms 이하) 또는 작은 영역 (15px 이하) → 고정 크기 마커
|
||
if (elapsed < 200 || (width < 15 && height < 15)) {
|
||
const xPercent = Math.max(0, selectionStart.xPercent - 2.5); // 중앙 정렬
|
||
const yPercent = Math.max(0, selectionStart.yPercent - 2.5);
|
||
openZoneItemModal(xPercent, yPercent, 5, 5);
|
||
} else {
|
||
// 드래그로 선택한 영역
|
||
const xPercent = Math.min(selectionStart.xPercent, (endX / rect.width) * 100);
|
||
const yPercent = Math.min(selectionStart.yPercent, (endY / rect.height) * 100);
|
||
const widthPercent = (width / rect.width) * 100;
|
||
const heightPercent = (height / rect.height) * 100;
|
||
openZoneItemModal(xPercent, yPercent, widthPercent, heightPercent);
|
||
}
|
||
|
||
selectionStart = null;
|
||
// 등록 모드 유지 (연속 등록 가능)
|
||
}
|
||
|
||
function onMapMouseLeave() {
|
||
// 지도 영역 벗어나면 현재 선택만 취소 (모드는 유지)
|
||
if (selectionBox && selectionStart) {
|
||
selectionBox.remove();
|
||
selectionBox = null;
|
||
selectionStart = null;
|
||
}
|
||
}
|
||
|
||
// 터치 이벤트 (모바일)
|
||
function onMapTouchStart(e) {
|
||
if (!isAddingItem) return;
|
||
if (e.target.closest('.zone-item-marker, .equipment-marker')) return;
|
||
e.preventDefault();
|
||
const touch = e.touches[0];
|
||
onMapMouseDown({ clientX: touch.clientX, clientY: touch.clientY, target: e.target });
|
||
}
|
||
|
||
function onMapTouchMove(e) {
|
||
if (!isAddingItem || !selectionStart) return;
|
||
e.preventDefault();
|
||
const touch = e.touches[0];
|
||
onMapMouseMove({ clientX: touch.clientX, clientY: touch.clientY });
|
||
}
|
||
|
||
function onMapTouchEnd(e) {
|
||
if (!isAddingItem || !selectionStart) return;
|
||
const touch = e.changedTouches[0];
|
||
onMapMouseUp({ clientX: touch.clientX, clientY: touch.clientY });
|
||
}
|
||
|
||
// 프로젝트 목록 캐시
|
||
let projectsCache = null;
|
||
let selectedPhotos = [];
|
||
|
||
// 프로젝트 목록 로드
|
||
async function loadProjects() {
|
||
if (projectsCache) return projectsCache;
|
||
try {
|
||
const response = await axios.get('/projects?status=in_progress&limit=100');
|
||
if (response.data.success) {
|
||
projectsCache = response.data.data || [];
|
||
return projectsCache;
|
||
}
|
||
} catch (err) {
|
||
console.error('프로젝트 로드 실패:', err);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// 프로젝트 타입 변경
|
||
function onProjectTypeChange(value) {
|
||
const projectGroup = document.getElementById('projectSelectGroup');
|
||
if (value === 'project') {
|
||
projectGroup.style.display = 'block';
|
||
populateProjectSelect();
|
||
} else {
|
||
projectGroup.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 프로젝트 셀렉트 채우기
|
||
async function populateProjectSelect() {
|
||
const select = document.getElementById('zoneItemProject');
|
||
const projects = await loadProjects();
|
||
|
||
select.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
|
||
projects.forEach(p => {
|
||
select.innerHTML += `<option value="${p.project_id}">${escapeHtml(p.project_name)}</option>`;
|
||
});
|
||
}
|
||
|
||
// 커스텀 유형 추가
|
||
function addCustomType() {
|
||
const customType = prompt('새로운 상태/유형을 입력하세요:');
|
||
if (!customType || !customType.trim()) return;
|
||
|
||
const select = document.getElementById('zoneItemType');
|
||
const value = customType.trim().toLowerCase().replace(/\s+/g, '_');
|
||
|
||
// 중복 체크
|
||
if (Array.from(select.options).some(opt => opt.value === value)) {
|
||
alert('이미 존재하는 유형입니다.');
|
||
return;
|
||
}
|
||
|
||
const option = document.createElement('option');
|
||
option.value = value;
|
||
option.textContent = customType.trim();
|
||
select.appendChild(option);
|
||
select.value = value;
|
||
}
|
||
|
||
// 사진 선택
|
||
function onPhotoSelected(event) {
|
||
const files = Array.from(event.target.files);
|
||
files.forEach(file => {
|
||
if (!file.type.startsWith('image/')) return;
|
||
if (selectedPhotos.length >= 5) {
|
||
alert('사진은 최대 5장까지 등록할 수 있습니다.');
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
selectedPhotos.push({
|
||
file: file,
|
||
dataUrl: e.target.result
|
||
});
|
||
renderPhotoPreview();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
event.target.value = ''; // 리셋
|
||
}
|
||
|
||
// 사진 미리보기 렌더링
|
||
function renderPhotoPreview() {
|
||
const container = document.getElementById('photoPreviewList');
|
||
container.innerHTML = selectedPhotos.map((photo, idx) => `
|
||
<div class="photo-preview-item">
|
||
<img src="${photo.dataUrl}" alt="사진 ${idx + 1}">
|
||
<button type="button" class="photo-remove" onclick="removePhoto(${idx})">×</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// 사진 제거
|
||
function removePhoto(index) {
|
||
selectedPhotos.splice(index, 1);
|
||
renderPhotoPreview();
|
||
}
|
||
|
||
// 현황 모달 열기
|
||
function openZoneItemModal(x, y, width, height, item = null) {
|
||
const modal = document.getElementById('zoneItemModal');
|
||
const title = document.getElementById('zoneItemModalTitle');
|
||
const deleteBtn = document.getElementById('deleteZoneItemBtn');
|
||
|
||
// 사진 초기화
|
||
selectedPhotos = [];
|
||
renderPhotoPreview();
|
||
|
||
if (item) {
|
||
// 수정 모드
|
||
title.textContent = '현황 수정';
|
||
deleteBtn.style.display = 'inline-block';
|
||
|
||
document.getElementById('zoneItemId').value = item.item_id;
|
||
document.getElementById('zoneItemX').value = item.x_percent;
|
||
document.getElementById('zoneItemY').value = item.y_percent;
|
||
document.getElementById('zoneItemWidth').value = item.width_percent || 5;
|
||
document.getElementById('zoneItemHeight').value = item.height_percent || 5;
|
||
document.getElementById('zoneItemName').value = item.item_name || '';
|
||
document.getElementById('zoneItemType').value = item.item_type || 'working';
|
||
document.getElementById('zoneItemWarning').value = item.warning_level || 'good';
|
||
document.getElementById('zoneItemDesc').value = item.description || '';
|
||
document.getElementById('zoneItemColor').value = item.color || '#3b82f6';
|
||
|
||
// 프로젝트 타입
|
||
const projectType = item.project_type || 'non_project';
|
||
document.querySelector(`input[name="zoneItemProjectType"][value="${projectType}"]`).checked = true;
|
||
onProjectTypeChange(projectType);
|
||
if (projectType === 'project' && item.project_id) {
|
||
setTimeout(() => {
|
||
document.getElementById('zoneItemProject').value = item.project_id;
|
||
}, 100);
|
||
}
|
||
|
||
// 기존 사진 로드 (있으면)
|
||
if (item.photos && item.photos.length > 0) {
|
||
item.photos.forEach(photo => {
|
||
selectedPhotos.push({
|
||
existing: true,
|
||
photo_id: photo.photo_id,
|
||
dataUrl: photo.photo_url
|
||
});
|
||
});
|
||
renderPhotoPreview();
|
||
}
|
||
} else {
|
||
// 등록 모드
|
||
title.textContent = '현황 등록';
|
||
deleteBtn.style.display = 'none';
|
||
|
||
document.getElementById('zoneItemId').value = '';
|
||
document.getElementById('zoneItemX').value = x;
|
||
document.getElementById('zoneItemY').value = y;
|
||
document.getElementById('zoneItemWidth').value = width;
|
||
document.getElementById('zoneItemHeight').value = height;
|
||
document.getElementById('zoneItemForm').reset();
|
||
document.getElementById('zoneItemColor').value = '#3b82f6';
|
||
|
||
// 기본값 설정
|
||
document.querySelector('input[name="zoneItemProjectType"][value="non_project"]').checked = true;
|
||
onProjectTypeChange('non_project');
|
||
}
|
||
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
// 현황 수정 모달 열기
|
||
function editZoneItem(itemId) {
|
||
const item = zoneItems.find(i => i.item_id === itemId);
|
||
if (!item) return;
|
||
openZoneItemModal(item.x_percent, item.y_percent, item.width_percent, item.height_percent, item);
|
||
}
|
||
|
||
// 모달 닫기
|
||
function closeZoneItemModal() {
|
||
document.getElementById('zoneItemModal').style.display = 'none';
|
||
}
|
||
|
||
// 색상 프리셋 선택
|
||
function setItemColor(color) {
|
||
document.getElementById('zoneItemColor').value = color;
|
||
}
|
||
|
||
// 현황 저장
|
||
async function saveZoneItem() {
|
||
const itemId = document.getElementById('zoneItemId').value;
|
||
const projectType = document.querySelector('input[name="zoneItemProjectType"]:checked')?.value || 'non_project';
|
||
const projectId = projectType === 'project' ? document.getElementById('zoneItemProject').value : null;
|
||
|
||
const itemData = {
|
||
item_name: document.getElementById('zoneItemName').value.trim(),
|
||
item_type: document.getElementById('zoneItemType').value,
|
||
warning_level: document.getElementById('zoneItemWarning').value,
|
||
project_type: projectType,
|
||
project_id: projectId || null,
|
||
description: document.getElementById('zoneItemDesc').value.trim(),
|
||
color: document.getElementById('zoneItemColor').value,
|
||
x_percent: parseFloat(document.getElementById('zoneItemX').value),
|
||
y_percent: parseFloat(document.getElementById('zoneItemY').value),
|
||
width_percent: parseFloat(document.getElementById('zoneItemWidth').value),
|
||
height_percent: parseFloat(document.getElementById('zoneItemHeight').value)
|
||
};
|
||
|
||
if (!itemData.item_name) {
|
||
alert('명칭을 입력해주세요.');
|
||
return;
|
||
}
|
||
|
||
if (projectType === 'project' && !projectId) {
|
||
alert('프로젝트를 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
let response;
|
||
if (itemId) {
|
||
// 수정
|
||
response = await axios.put(`/patrol/zone-items/${itemId}`, itemData);
|
||
} else {
|
||
// 등록
|
||
response = await axios.post(`/patrol/workplaces/${workplaceId}/zone-items`, itemData);
|
||
}
|
||
|
||
if (response.data.success) {
|
||
const savedItemId = itemId || response.data.data?.item_id;
|
||
|
||
// 사진 업로드 (새로 추가된 사진만)
|
||
const newPhotos = selectedPhotos.filter(p => !p.existing && p.file);
|
||
if (newPhotos.length > 0 && savedItemId) {
|
||
await uploadZoneItemPhotos(savedItemId, newPhotos);
|
||
}
|
||
|
||
closeZoneItemModal();
|
||
await loadZoneItems();
|
||
alert(itemId ? '현황이 수정되었습니다.' : '현황이 등록되었습니다.');
|
||
} else {
|
||
throw new Error(response.data.message || '저장 실패');
|
||
}
|
||
} catch (error) {
|
||
console.error('현황 저장 실패:', error);
|
||
alert('저장에 실패했습니다: ' + (error.response?.data?.message || error.message));
|
||
}
|
||
}
|
||
|
||
// 사진 업로드
|
||
async function uploadZoneItemPhotos(itemId, photos) {
|
||
for (const photo of photos) {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('photo', photo.file);
|
||
formData.append('item_id', itemId);
|
||
|
||
await axios.post('/patrol/zone-items/photos', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' }
|
||
});
|
||
} catch (err) {
|
||
console.error('사진 업로드 실패:', err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 현황 삭제
|
||
async function deleteZoneItem() {
|
||
const itemId = document.getElementById('zoneItemId').value;
|
||
if (!itemId) return;
|
||
|
||
if (!confirm('이 현황을 삭제하시겠습니까?')) return;
|
||
|
||
try {
|
||
const response = await axios.delete(`/patrol/zone-items/${itemId}`);
|
||
if (response.data.success) {
|
||
closeZoneItemModal();
|
||
selectedZoneItem = null;
|
||
await loadZoneItems();
|
||
alert('현황이 삭제되었습니다.');
|
||
} else {
|
||
throw new Error(response.data.message || '삭제 실패');
|
||
}
|
||
} catch (error) {
|
||
console.error('현황 삭제 실패:', error);
|
||
alert('삭제에 실패했습니다: ' + (error.response?.data?.message || error.message));
|
||
}
|
||
}
|
||
|
||
// 요약 카드 렌더링
|
||
function renderSummaryCards(summary) {
|
||
const container = document.getElementById('summaryCards');
|
||
|
||
container.innerHTML = `
|
||
<div class="summary-card">
|
||
<div class="summary-card-icon equipment">⚙️</div>
|
||
<div class="summary-card-content">
|
||
<div class="summary-card-value">${summary.equipmentCount}</div>
|
||
<div class="summary-card-label">등록 설비</div>
|
||
</div>
|
||
</div>
|
||
<div class="summary-card ${summary.pendingRepairs > 0 ? 'warning' : ''}">
|
||
<div class="summary-card-icon repair">🔧</div>
|
||
<div class="summary-card-content">
|
||
<div class="summary-card-value">${summary.pendingRepairs}</div>
|
||
<div class="summary-card-label">수리 요청</div>
|
||
</div>
|
||
</div>
|
||
<div class="summary-card ${summary.openIssues > 0 ? 'danger' : ''}">
|
||
<div class="summary-card-icon issues">🚨</div>
|
||
<div class="summary-card-content">
|
||
<div class="summary-card-value">${summary.openIssues}</div>
|
||
<div class="summary-card-label">미해결 신고</div>
|
||
</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-card-icon visits">🚶</div>
|
||
<div class="summary-card-content">
|
||
<div class="summary-card-value">${summary.todayVisitors}</div>
|
||
<div class="summary-card-label">금일 방문자</div>
|
||
</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-card-icon tbm">📋</div>
|
||
<div class="summary-card-content">
|
||
<div class="summary-card-value">${summary.todayTbmSessions}</div>
|
||
<div class="summary-card-label">금일 TBM</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 안전신고/부적합 탭 렌더링
|
||
function renderIssuesTab() {
|
||
const container = document.getElementById('issuesContent');
|
||
const { workIssues } = workplaceData;
|
||
|
||
if (!workIssues.all.length) {
|
||
container.innerHTML = `
|
||
<div class="content-empty">
|
||
<div class="content-empty-icon">✅</div>
|
||
<div class="content-empty-text">최근 30일간 신고 내역이 없습니다.</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
|
||
// 안전 신고
|
||
if (workIssues.safety.length > 0) {
|
||
html += `
|
||
<div class="content-section">
|
||
<h3 class="section-title">
|
||
🛡️ 안전 신고
|
||
<span class="section-badge danger">${workIssues.safety.length}</span>
|
||
</h3>
|
||
<div class="card-list">
|
||
${workIssues.safety.map(issue => renderIssueCard(issue, 'safety')).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 부적합 사항
|
||
if (workIssues.nonconformity.length > 0) {
|
||
html += `
|
||
<div class="content-section">
|
||
<h3 class="section-title">
|
||
⚠️ 부적합 사항
|
||
<span class="section-badge warning">${workIssues.nonconformity.length}</span>
|
||
</h3>
|
||
<div class="card-list">
|
||
${workIssues.nonconformity.map(issue => renderIssueCard(issue, 'nonconformity')).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// 신고 카드 렌더링
|
||
function renderIssueCard(issue, type) {
|
||
const statusLabels = {
|
||
'pending': '대기',
|
||
'received': '접수',
|
||
'in_progress': '처리중',
|
||
'completed': '완료',
|
||
'closed': '종료'
|
||
};
|
||
const severityLabels = {
|
||
'low': '경미',
|
||
'medium': '보통',
|
||
'high': '중요',
|
||
'critical': '긴급'
|
||
};
|
||
|
||
return `
|
||
<div class="issue-card ${type}">
|
||
<div class="issue-card-header">
|
||
<span class="issue-card-title">${escapeHtml(issue.title)}</span>
|
||
<span class="issue-card-status ${issue.status}">${statusLabels[issue.status] || issue.status}</span>
|
||
</div>
|
||
<div class="issue-card-meta">
|
||
<span>📁 ${escapeHtml(issue.category_name || '미분류')}</span>
|
||
${issue.severity ? `<span class="issue-card-severity ${issue.severity}">${severityLabels[issue.severity]}</span>` : ''}
|
||
<span>📅 ${formatDateTime(issue.created_at)}</span>
|
||
<span>👤 ${escapeHtml(issue.reporter_name || '익명')}</span>
|
||
</div>
|
||
${issue.description ? `<div class="issue-card-desc">${escapeHtml(issue.description)}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 설비/수리 탭 렌더링
|
||
function renderEquipmentTab() {
|
||
const container = document.getElementById('equipmentContent');
|
||
const { equipments, repairRequests } = workplaceData;
|
||
|
||
let html = '';
|
||
|
||
// 수리 요청
|
||
if (repairRequests.length > 0) {
|
||
html += `
|
||
<div class="content-section">
|
||
<h3 class="section-title">
|
||
🔧 수리 요청
|
||
<span class="section-badge warning">${repairRequests.length}</span>
|
||
</h3>
|
||
<div class="card-list">
|
||
${repairRequests.map(req => renderRepairCard(req)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 설비 현황
|
||
if (equipments.length > 0) {
|
||
html += `
|
||
<div class="content-section">
|
||
<h3 class="section-title">
|
||
⚙️ 설비 현황
|
||
<span class="section-badge">${equipments.length}</span>
|
||
</h3>
|
||
<div class="card-list">
|
||
${equipments.map(eq => renderEquipmentCard(eq)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (!html) {
|
||
html = `
|
||
<div class="content-empty">
|
||
<div class="content-empty-icon">⚙️</div>
|
||
<div class="content-empty-text">등록된 설비가 없습니다.</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// 수리 요청 카드 렌더링
|
||
function renderRepairCard(req) {
|
||
const priorityLabels = {
|
||
'emergency': '긴급',
|
||
'high': '높음',
|
||
'normal': '보통',
|
||
'low': '낮음'
|
||
};
|
||
|
||
return `
|
||
<div class="repair-card ${req.priority}">
|
||
<div class="repair-card-header">
|
||
<span class="repair-card-equipment">${escapeHtml(req.equipment_name)}</span>
|
||
<span class="repair-card-priority ${req.priority}">${priorityLabels[req.priority] || req.priority}</span>
|
||
</div>
|
||
<div class="repair-card-category">📋 ${escapeHtml(req.repair_category)}</div>
|
||
${req.description ? `<div class="repair-card-desc">${escapeHtml(req.description)}</div>` : ''}
|
||
<div class="repair-card-date">📅 ${formatDate(req.request_date)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 설비 카드 렌더링
|
||
function renderEquipmentCard(eq) {
|
||
const statusLabels = {
|
||
'active': '정상',
|
||
'inactive': '비활성',
|
||
'repair_needed': '수리필요',
|
||
'under_repair': '수리중',
|
||
'disposed': '폐기'
|
||
};
|
||
|
||
return `
|
||
<div class="equipment-card ${eq.needs_attention ? 'attention' : ''}">
|
||
<div class="equipment-card-icon">⚙️</div>
|
||
<div class="equipment-card-info">
|
||
<div class="equipment-card-name">${escapeHtml(eq.equipment_name)}</div>
|
||
<div class="equipment-card-code">${escapeHtml(eq.equipment_type || '-')}</div>
|
||
</div>
|
||
<span class="equipment-card-status ${eq.status}">${statusLabels[eq.status] || eq.status}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 출입현황 탭 렌더링
|
||
function renderVisitsTab() {
|
||
const container = document.getElementById('visitsContent');
|
||
const { visitRecords } = workplaceData;
|
||
|
||
if (!visitRecords.length) {
|
||
container.innerHTML = `
|
||
<div class="content-empty">
|
||
<div class="content-empty-icon">🚶</div>
|
||
<div class="content-empty-text">금일 승인된 방문자가 없습니다.</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="content-section">
|
||
<h3 class="section-title">
|
||
🚶 금일 방문자
|
||
<span class="section-badge">${visitRecords.length}</span>
|
||
</h3>
|
||
<div class="card-list">
|
||
${visitRecords.map(visit => renderVisitCard(visit)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 방문자 카드 렌더링
|
||
function renderVisitCard(visit) {
|
||
return `
|
||
<div class="visit-card">
|
||
<div class="visit-card-header">
|
||
<div>
|
||
<span class="visit-card-name">${escapeHtml(visit.visitor_name)}</span>
|
||
${visit.visitor_company ? `<span class="visit-card-company">(${escapeHtml(visit.visitor_company)})</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="visit-card-purpose">${escapeHtml(visit.purpose_name || visit.visit_purpose || '')}</div>
|
||
<div class="visit-card-details">
|
||
<span>🕐 ${escapeHtml(visit.visit_time_from || '')} ~ ${escapeHtml(visit.visit_time_to || '')}</span>
|
||
${visit.companion_count > 0 ? `<span>👥 동행 ${visit.companion_count}명</span>` : ''}
|
||
${visit.vehicle_number ? `<span>🚗 ${escapeHtml(visit.vehicle_number)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// TBM 탭 렌더링
|
||
function renderTbmTab() {
|
||
const container = document.getElementById('tbmContent');
|
||
const { tbmSessions } = workplaceData;
|
||
|
||
if (!tbmSessions.length) {
|
||
container.innerHTML = `
|
||
<div class="content-empty">
|
||
<div class="content-empty-icon">📋</div>
|
||
<div class="content-empty-text">금일 TBM 세션이 없습니다.</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="content-section">
|
||
<h3 class="section-title">
|
||
📋 금일 TBM
|
||
<span class="section-badge">${tbmSessions.length}</span>
|
||
</h3>
|
||
<div class="card-list">
|
||
${tbmSessions.map(tbm => renderTbmCard(tbm)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// TBM 카드 렌더링
|
||
function renderTbmCard(tbm) {
|
||
const statusLabels = {
|
||
'draft': '작성중',
|
||
'in_progress': '진행중',
|
||
'completed': '완료'
|
||
};
|
||
|
||
return `
|
||
<div class="tbm-card">
|
||
<div class="tbm-card-header">
|
||
<span class="tbm-card-task">${escapeHtml(tbm.task_name || tbm.work_type_name || '작업')}</span>
|
||
<span class="tbm-card-status ${tbm.status}">${statusLabels[tbm.status] || tbm.status}</span>
|
||
</div>
|
||
<div class="tbm-card-info">
|
||
<span>📍 ${escapeHtml(tbm.work_location || '-')}</span>
|
||
<span>👷 ${escapeHtml(tbm.leader_name || tbm.leader_worker_name || '-')}</span>
|
||
<span>👥 ${tbm.team_size || (tbm.team ? tbm.team.length : 0)}명</span>
|
||
</div>
|
||
${tbm.work_content ? `
|
||
<div class="tbm-card-content">
|
||
<div class="tbm-card-content-title">작업내용</div>
|
||
<div class="tbm-card-content-text">${escapeHtml(tbm.work_content)}</div>
|
||
</div>
|
||
` : ''}
|
||
${tbm.safety_measures ? `
|
||
<div class="tbm-card-content">
|
||
<div class="tbm-card-content-title">안전조치</div>
|
||
<div class="tbm-card-content-text">${escapeHtml(tbm.safety_measures)}</div>
|
||
</div>
|
||
` : ''}
|
||
${tbm.team && tbm.team.length > 0 ? `
|
||
<div class="tbm-card-team">
|
||
<div class="tbm-card-team-title">참석자 (${tbm.team.length}명)</div>
|
||
<div class="tbm-card-team-list">
|
||
${tbm.team.map(m => `
|
||
<span class="tbm-team-member ${m.signature_image ? 'signed' : ''}">${escapeHtml(m.worker_name)}</span>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 순회점검 탭 렌더링
|
||
function renderPatrolTab() {
|
||
const container = document.getElementById('patrolContent');
|
||
const { recentPatrol } = workplaceData;
|
||
|
||
if (!recentPatrol || !recentPatrol.length) {
|
||
container.innerHTML = `
|
||
<div class="content-empty">
|
||
<div class="content-empty-icon">🔍</div>
|
||
<div class="content-empty-text">최근 7일간 순회점검 기록이 없습니다.</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="content-section">
|
||
<h3 class="section-title">
|
||
🔍 최근 순회점검
|
||
<span class="section-badge">${recentPatrol.length}</span>
|
||
</h3>
|
||
<div class="card-list">
|
||
${recentPatrol.map(patrol => renderPatrolCard(patrol)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 순회점검 카드 렌더링
|
||
function renderPatrolCard(patrol) {
|
||
const timeLabels = { 'morning': '오전', 'afternoon': '오후' };
|
||
|
||
return `
|
||
<div class="patrol-card">
|
||
<div class="patrol-card-header">
|
||
<span class="patrol-card-date">${formatDate(patrol.patrol_date)}</span>
|
||
<span class="patrol-card-time">${timeLabels[patrol.patrol_time] || patrol.patrol_time}</span>
|
||
</div>
|
||
<div class="patrol-card-info">
|
||
<span>👤 ${escapeHtml(patrol.inspector_name || '-')}</span>
|
||
<span>📊 ${patrol.status === 'completed' ? '완료' : '진행중'}</span>
|
||
</div>
|
||
<div class="patrol-card-stats">
|
||
<div class="patrol-stat">
|
||
<div class="patrol-stat-value">${patrol.checked_count || 0}</div>
|
||
<div class="patrol-stat-label">점검 항목</div>
|
||
</div>
|
||
<div class="patrol-stat">
|
||
<div class="patrol-stat-value ${patrol.issue_count > 0 ? 'warning' : ''}">${patrol.issue_count || 0}</div>
|
||
<div class="patrol-stat-label">주의/불량</div>
|
||
</div>
|
||
</div>
|
||
${patrol.notes ? `<div class="patrol-card-notes">📝 ${escapeHtml(patrol.notes)}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 탭 전환
|
||
function switchTab(tabName) {
|
||
// 탭 버튼 활성화
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||
});
|
||
|
||
// 탭 콘텐츠 표시
|
||
document.querySelectorAll('.tab-content').forEach(content => {
|
||
content.classList.toggle('active', content.id === `tab-${tabName}`);
|
||
});
|
||
}
|
||
|
||
// 뒤로 가기
|
||
function goBack() {
|
||
// 이전 페이지로 이동, 없으면 일일순회점검 페이지로
|
||
if (document.referrer && document.referrer.includes(window.location.host)) {
|
||
window.history.back();
|
||
} else {
|
||
window.location.href = '/pages/inspection/daily-patrol.html';
|
||
}
|
||
}
|
||
|
||
// 유틸리티 함수
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '';
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleDateString('ko-KR', { month: 'long', day: 'numeric', weekday: 'short' });
|
||
}
|
||
|
||
function formatDateTime(dateStr) {
|
||
if (!dateStr) return '';
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
}
|