Files
Hyungi Ahn 93edf9529a refactor: 보안 취약점 제거 + 데드코드 정리 + 프론트엔드 중복 통합
- 인증 없는 임시 엔드포인트 삭제 (index.js, healthRoutes.js, publicPaths)
- skipAuth 우회 라우트 삭제 (workAnalysis.js)
- 하드코딩 유저 백도어 삭제 (routes/auth.js)
- 안전체크 CRUD에 admin 권한 추가 (tbmRoutes.js)
- deprecated shim 3개 삭제 + 8개 소비 파일 import 정리 (auth.js 직접 참조)
- 미사용 pageAccessController, db.js, common/security.js 삭제
- escapeHtml() 5곳 로컬 중복 제거 → api-base.js 전역 사용
- userPageAccess_v2_v2 캐시 키 버그 수정 (app-init.js)
- system3 .bak 파일 삭제, PROGRESS.md 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:19:01 +09:00

1415 lines
46 KiB
JavaScript
Raw Permalink 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.
// zone-detail.js - 구역 상세 페이지 JavaScript
// 전역 상태
let workplaceId = null;
let workplaceData = null;
let zoneItems = [];
let selectedZoneItem = null;
let isAddingItem = false;
let selectionStart = null;
let selectionBox = null;
// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공
// 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' });
}