/* ===== Workplaces CRUD ===== */ let workplaces = [], workplacesLoaded = false, workplaceCategories = []; let selectedWorkplaceId = null, selectedWorkplaceName = ''; let equipments = [], equipmentTypes = []; let wpNavLevel = 'categories'; // 'categories' | 'workplaces' let wpNavCategoryId = null; let wpNavCategoryName = ''; let previewMapRegions = []; function purposeBadge(p) { const colors = { '작업구역': 'bg-blue-50 text-blue-600', '창고': 'bg-amber-50 text-amber-600', '설비': 'bg-purple-50 text-purple-600', '휴게시설': 'bg-green-50 text-green-600' }; return p ? `${p}` : ''; } async function loadWorkplaceCategories() { try { const r = await api('/workplaces/categories'); workplaceCategories = r.data || r; populateCategorySelects(); renderSidebar(); } catch(e) { console.warn('카테고리 로드 실패:', e); } } function populateCategorySelects() { ['newWorkplaceCategory','editWorkplaceCategory'].forEach(id => { const sel = document.getElementById(id); if (!sel) return; const val = sel.value; sel.innerHTML = ''; workplaceCategories.forEach(c => { const o = document.createElement('option'); o.value = c.category_id; o.textContent = c.category_name; sel.appendChild(o); }); sel.value = val; }); } async function loadWorkplaces() { await loadWorkplaceCategories(); try { const r = await api('/workplaces'); workplaces = r.data || r; workplacesLoaded = true; renderSidebar(); } catch (err) { document.getElementById('wpSidebarContent').innerHTML = `

${err.message}

`; } } function renderSidebar() { const c = document.getElementById('wpSidebarContent'); if (!c) return; let html = ''; if (wpNavLevel === 'categories') { // 공장 목록 레벨 html += '
공장 선택
'; html += ``; if (!workplaceCategories.length) { html += '

등록된 공장이 없습니다.

'; } else { html += '
'; workplaceCategories.forEach(cat => { const count = workplaces.filter(w => w.category_id == cat.category_id).length; html += `
${cat.category_name}
${count}
`; }); // 미분류 작업장 const uncategorized = workplaces.filter(w => !w.category_id); if (uncategorized.length) { html += `
미분류
${uncategorized.length}
`; } html += '
'; } } else { // 작업장 목록 레벨 (특정 공장 내) html += ``; html += `
${wpNavCategoryName}
`; html += ``; const filtered = wpNavCategoryId === 0 ? workplaces.filter(w => !w.category_id) : workplaces.filter(w => w.category_id == wpNavCategoryId); if (!filtered.length) { html += '

등록된 작업장이 없습니다.

'; } else { html += '
'; filtered.forEach(w => { html += `
${w.workplace_name}
${purposeBadge(w.workplace_purpose)} ${w.is_active === 0 || w.is_active === false ? '비활성' : ''}
${w.is_active !== 0 && w.is_active !== false ? `` : ''}
`; }); html += '
'; } } c.innerHTML = html; } function drillIntoCategory(categoryId, categoryName) { wpNavLevel = 'workplaces'; wpNavCategoryId = categoryId; wpNavCategoryName = categoryName; selectedWorkplaceId = null; renderSidebar(); showZoneMapForCategory(categoryId); } function backToCategories() { wpNavLevel = 'categories'; wpNavCategoryId = null; wpNavCategoryName = ''; selectedWorkplaceId = null; renderSidebar(); showEmptyState(); } function showEmptyState() { document.getElementById('workplaceEmptyState')?.classList.remove('hidden'); document.getElementById('equipmentSection')?.classList.add('hidden'); document.getElementById('zoneMapSection')?.classList.add('hidden'); } function showZoneMapForCategory(categoryId) { document.getElementById('workplaceEmptyState')?.classList.add('hidden'); document.getElementById('equipmentSection')?.classList.add('hidden'); document.getElementById('zoneMapSection')?.classList.remove('hidden'); const catName = categoryId === 0 ? '미분류' : (workplaceCategories.find(c => c.category_id == categoryId)?.category_name || ''); document.getElementById('zoneMapTitle').innerHTML = `${catName} - 구역지도`; selectedMapCategoryId = categoryId; if (categoryId === 0) { document.getElementById('layoutPreviewArea').classList.remove('hidden'); document.getElementById('layoutPreviewCanvas').classList.add('hidden'); document.getElementById('layoutPreviewArea').innerHTML = '

미분류 작업장에는 구역지도가 없습니다.

'; return; } loadLayoutPreview(categoryId); } function backToCategory() { if (!wpNavCategoryId && wpNavCategoryId !== 0) { backToCategories(); return; } selectedWorkplaceId = null; renderSidebar(); showZoneMapForCategory(wpNavCategoryId); } function openAddWorkplaceModal() { populateCategorySelects(); document.getElementById('addWorkplaceForm').reset(); // 공장 드릴다운 상태이면 카테고리 자동 선택 if (wpNavLevel === 'workplaces' && wpNavCategoryId && wpNavCategoryId !== 0) { document.getElementById('newWorkplaceCategory').value = wpNavCategoryId; } document.getElementById('addWorkplaceModal').classList.remove('hidden'); } function closeAddWorkplaceModal() { document.getElementById('addWorkplaceModal').classList.add('hidden'); } document.getElementById('addWorkplaceForm').addEventListener('submit', async e => { e.preventDefault(); try { await api('/workplaces', { method: 'POST', body: JSON.stringify({ workplace_name: document.getElementById('newWorkplaceName').value.trim(), category_id: document.getElementById('newWorkplaceCategory').value ? parseInt(document.getElementById('newWorkplaceCategory').value) : null, workplace_purpose: document.getElementById('newWorkplacePurpose').value || null, description: document.getElementById('newWorkplaceDesc').value.trim() || null, display_priority: parseInt(document.getElementById('newWorkplacePriority').value) || 0 })}); showToast('작업장이 추가되었습니다.'); document.getElementById('addWorkplaceForm').reset(); closeAddWorkplaceModal(); await loadWorkplaces(); } catch(e) { showToast(e.message, 'error'); } }); function editWorkplace(id) { const w = workplaces.find(x => x.workplace_id === id); if (!w) return; document.getElementById('editWorkplaceId').value = w.workplace_id; document.getElementById('editWorkplaceName').value = w.workplace_name; document.getElementById('editWorkplaceDesc').value = w.description || ''; document.getElementById('editWorkplacePriority').value = w.display_priority || 0; document.getElementById('editWorkplaceActive').value = (w.is_active === 0 || w.is_active === false) ? '0' : '1'; document.getElementById('editWorkplacePurpose').value = w.workplace_purpose || ''; populateCategorySelects(); document.getElementById('editWorkplaceCategory').value = w.category_id || ''; document.getElementById('editWorkplaceModal').classList.remove('hidden'); } function closeWorkplaceModal() { document.getElementById('editWorkplaceModal').classList.add('hidden'); } document.getElementById('editWorkplaceForm').addEventListener('submit', async e => { e.preventDefault(); try { await api(`/workplaces/${document.getElementById('editWorkplaceId').value}`, { method: 'PUT', body: JSON.stringify({ workplace_name: document.getElementById('editWorkplaceName').value.trim(), category_id: document.getElementById('editWorkplaceCategory').value ? parseInt(document.getElementById('editWorkplaceCategory').value) : null, workplace_purpose: document.getElementById('editWorkplacePurpose').value || null, description: document.getElementById('editWorkplaceDesc').value.trim() || null, display_priority: parseInt(document.getElementById('editWorkplacePriority').value) || 0, is_active: document.getElementById('editWorkplaceActive').value === '1' })}); showToast('수정되었습니다.'); closeWorkplaceModal(); await loadWorkplaces(); } catch(e) { showToast(e.message, 'error'); } }); async function deactivateWorkplace(id, name) { if (!confirm(`"${name}" 작업장을 비활성화?`)) return; try { await api(`/workplaces/${id}`, { method: 'DELETE' }); showToast('작업장 비활성화 완료'); await loadWorkplaces(); } catch(e) { showToast(e.message, 'error'); } } /* ===== Equipment CRUD ===== */ let eqMapImg = null, eqMapCanvas = null, eqMapCtx = null, eqDetailEqId = null; function eqStatusBadge(status) { const map = { active:'bg-emerald-50 text-emerald-600', maintenance:'bg-amber-50 text-amber-600', inactive:'bg-gray-100 text-gray-500', external:'bg-blue-50 text-blue-600', repair_external:'bg-blue-50 text-blue-600', repair_needed:'bg-red-50 text-red-600' }; const labels = { active:'가동중', maintenance:'점검중', inactive:'비활성', external:'외부반출', repair_external:'수리외주', repair_needed:'수리필요' }; return `${labels[status] || status || ''}`; } function selectWorkplaceForEquipments(id, name) { selectedWorkplaceId = id; selectedWorkplaceName = name; // 카테고리 레벨에서 직접 호출된 경우, 해당 카테고리로 드릴인 if (wpNavLevel === 'categories') { const wp = workplaces.find(w => w.workplace_id === id); if (wp && wp.category_id) { wpNavLevel = 'workplaces'; wpNavCategoryId = wp.category_id; wpNavCategoryName = wp.category_name || ''; } } renderSidebar(); document.getElementById('workplaceEmptyState')?.classList.add('hidden'); document.getElementById('zoneMapSection')?.classList.add('hidden'); document.getElementById('equipmentSection').classList.remove('hidden'); document.getElementById('eqWorkplaceName').textContent = name; // 뒤로가기 버튼 표시 (공장 구역지도로 돌아가기) const backBtn = document.getElementById('eqBackToCategory'); if (backBtn && wpNavCategoryId !== null) { document.getElementById('eqBackLabel').textContent = `${wpNavCategoryName} 구역지도`; backBtn.classList.remove('hidden'); } else if (backBtn) { backBtn.classList.add('hidden'); } loadEquipments(); loadEquipmentTypes(); loadEqMap(); } async function loadEquipments() { try { const r = await api(`/equipments/workplace/${selectedWorkplaceId}`); equipments = r.data || []; displayEquipments(); drawEqMapEquipments(); } catch(e) { document.getElementById('equipmentList').innerHTML = `

${e.message}

`; } } function displayEquipments() { const statusFilter = document.getElementById('eqStatusFilter').value; const typeFilter = document.getElementById('eqTypeFilter').value; let filtered = equipments; if (statusFilter) filtered = filtered.filter(e => e.status === statusFilter); if (typeFilter) filtered = filtered.filter(e => e.equipment_type === typeFilter); const c = document.getElementById('equipmentList'); if (!filtered.length) { c.innerHTML = '

설비가 없습니다.

'; return; } c.innerHTML = filtered.map(e => { const placed = e.map_x_percent != null && e.map_y_percent != null; return `
${e.equipment_code || ''}${e.equipment_name} ${e.is_temporarily_moved ? '' : ''}
${e.equipment_type ? `${e.equipment_type}` : ''} ${e.manufacturer ? `${e.manufacturer}` : ''} ${eqStatusBadge(e.status)} ${placed ? ' 배치됨' : '미배치'}
`; }).join(''); } function filterEquipments() { displayEquipments(); } async function loadEquipmentTypes() { try { const r = await api('/equipments/types'); equipmentTypes = r.data || []; const sel = document.getElementById('eqTypeFilter'); const val = sel.value; sel.innerHTML = ''; equipmentTypes.forEach(t => { const o = document.createElement('option'); o.value = t; o.textContent = t; sel.appendChild(o); }); sel.value = val; const dl = document.getElementById('eqTypeDatalist'); if (dl) { dl.innerHTML = ''; equipmentTypes.forEach(t => { const o = document.createElement('option'); o.value = t; dl.appendChild(o); }); } } catch(e) { console.warn('설비 유형 로드 실패:', e); } } async function openEquipmentModal(editId) { document.getElementById('eqEditId').value = ''; document.getElementById('equipmentForm').reset(); if (editId) { document.getElementById('eqModalTitle').textContent = '설비 수정'; const eq = equipments.find(e => e.equipment_id === editId); if (!eq) return; document.getElementById('eqEditId').value = eq.equipment_id; document.getElementById('eqCode').value = eq.equipment_code || ''; document.getElementById('eqName').value = eq.equipment_name || ''; document.getElementById('eqType').value = eq.equipment_type || ''; document.getElementById('eqStatus').value = eq.status || 'active'; document.getElementById('eqManufacturer').value = eq.manufacturer || ''; document.getElementById('eqModel').value = eq.model_name || ''; document.getElementById('eqSupplier').value = eq.supplier || ''; document.getElementById('eqPrice').value = eq.purchase_price || ''; document.getElementById('eqInstallDate').value = eq.installation_date ? eq.installation_date.substring(0, 10) : ''; document.getElementById('eqSerial').value = eq.serial_number || ''; document.getElementById('eqSpecs').value = eq.specifications || ''; document.getElementById('eqNotes').value = eq.notes || ''; } else { document.getElementById('eqModalTitle').textContent = '설비 추가'; generateEquipmentCode(); } document.getElementById('equipmentModal').classList.remove('hidden'); } function closeEquipmentModal() { document.getElementById('equipmentModal').classList.add('hidden'); } async function generateEquipmentCode() { try { const r = await api('/equipments/next-code?prefix=TKP'); document.getElementById('eqCode').value = r.data || ''; } catch(e) {} } function editEquipment(id) { openEquipmentModal(id); } async function deleteEquipment(id, name) { if (!confirm(`"${name}" 설비를 삭제하시겠습니까?`)) return; try { await api(`/equipments/${id}`, { method: 'DELETE' }); showToast('설비가 삭제되었습니다.'); await loadEquipments(); } catch(e) { showToast(e.message, 'error'); } } document.getElementById('equipmentForm').addEventListener('submit', async e => { e.preventDefault(); const editId = document.getElementById('eqEditId').value; const body = { equipment_code: document.getElementById('eqCode').value.trim(), equipment_name: document.getElementById('eqName').value.trim(), equipment_type: document.getElementById('eqType').value.trim() || null, status: document.getElementById('eqStatus').value, manufacturer: document.getElementById('eqManufacturer').value.trim() || null, model_name: document.getElementById('eqModel').value.trim() || null, supplier: document.getElementById('eqSupplier').value.trim() || null, purchase_price: document.getElementById('eqPrice').value ? parseFloat(document.getElementById('eqPrice').value) : null, installation_date: document.getElementById('eqInstallDate').value || null, serial_number: document.getElementById('eqSerial').value.trim() || null, specifications: document.getElementById('eqSpecs').value.trim() || null, notes: document.getElementById('eqNotes').value.trim() || null, workplace_id: selectedWorkplaceId }; try { if (editId) { await api(`/equipments/${editId}`, { method: 'PUT', body: JSON.stringify(body) }); showToast('설비가 수정되었습니다.'); } else { await api('/equipments', { method: 'POST', body: JSON.stringify(body) }); showToast('설비가 추가되었습니다.'); } closeEquipmentModal(); await loadEquipments(); loadEquipmentTypes(); } catch(e) { showToast(e.message, 'error'); } }); /* ===== Equipment Map (설비 배치도) ===== */ function loadEqMap() { const wp = workplaces.find(w => w.workplace_id === selectedWorkplaceId); if (!wp || !wp.layout_image) { document.getElementById('eqMapArea').classList.remove('hidden'); document.getElementById('eqMapCanvasWrap').classList.add('hidden'); return; } document.getElementById('eqMapArea').classList.add('hidden'); document.getElementById('eqMapCanvasWrap').classList.remove('hidden'); eqMapCanvas = document.getElementById('eqMapCanvas'); eqMapCtx = eqMapCanvas.getContext('2d'); const imgUrl = wp.layout_image.startsWith('/') ? '/uploads/' + wp.layout_image.replace(/^\/uploads\//, '') : wp.layout_image; const img = new Image(); img.onload = function() { const maxW = 800; const scale = img.width > maxW ? maxW / img.width : 1; eqMapCanvas.width = img.width * scale; eqMapCanvas.height = img.height * scale; eqMapCtx.drawImage(img, 0, 0, eqMapCanvas.width, eqMapCanvas.height); eqMapImg = img; drawEqMapEquipments(); initEqMapEvents(); }; img.src = imgUrl; } // 좌표계: map_x_percent/map_y_percent = 좌상단 기준 (tkfb CSS left/top 과 동일) const EQ_DEFAULT_W = 8, EQ_DEFAULT_H = 6; function drawEqMapEquipments() { if (!eqMapCanvas || !eqMapCtx || !eqMapImg) return; eqMapCtx.clearRect(0, 0, eqMapCanvas.width, eqMapCanvas.height); eqMapCtx.drawImage(eqMapImg, 0, 0, eqMapCanvas.width, eqMapCanvas.height); equipments.forEach(eq => { const xPct = eq._dragX != null ? eq._dragX : eq.map_x_percent; const yPct = eq._dragY != null ? eq._dragY : eq.map_y_percent; if (xPct == null || yPct == null) return; const wPct = eq._dragW != null ? eq._dragW : (eq.map_width_percent || EQ_DEFAULT_W); const hPct = eq._dragH != null ? eq._dragH : (eq.map_height_percent || EQ_DEFAULT_H); // 좌상단 기준 const left = (xPct / 100) * eqMapCanvas.width; const top = (yPct / 100) * eqMapCanvas.height; const w = (wPct / 100) * eqMapCanvas.width; const h = (hPct / 100) * eqMapCanvas.height; const colors = { active:'#10b981', maintenance:'#f59e0b', inactive:'#94a3b8', external:'#3b82f6', repair_external:'#3b82f6', repair_needed:'#ef4444' }; const isActive = eq._dragX != null || eq._dragW != null; const color = colors[eq.status] || '#64748b'; // 배경 eqMapCtx.fillStyle = (isActive ? color + '55' : color + '33'); eqMapCtx.fillRect(left, top, w, h); // 테두리 eqMapCtx.strokeStyle = isActive ? '#f59e0b' : color; eqMapCtx.lineWidth = isActive ? 3 : 2; if (isActive) eqMapCtx.setLineDash([4, 3]); eqMapCtx.strokeRect(left, top, w, h); if (isActive) eqMapCtx.setLineDash([]); // 라벨 (하단) eqMapCtx.fillStyle = color; eqMapCtx.font = 'bold 10px sans-serif'; eqMapCtx.textAlign = 'start'; eqMapCtx.fillText(eq.equipment_code || eq.equipment_name, left, top + h + 12); // 리사이즈 모서리 핸들 (4개) const corners = [ { cx: left, cy: top }, { cx: left + w, cy: top }, { cx: left, cy: top + h }, { cx: left + w, cy: top + h }, ]; corners.forEach(c => { eqMapCtx.fillStyle = '#fff'; eqMapCtx.fillRect(c.cx - 3, c.cy - 3, 6, 6); eqMapCtx.strokeStyle = color; eqMapCtx.lineWidth = 1.5; eqMapCtx.strokeRect(c.cx - 3, c.cy - 3, 6, 6); }); }); } let eqMapPlacingId = null; let eqDraggingId = null, eqDragOffsetX = 0, eqDragOffsetY = 0, eqDragMoved = false; let eqResizing = null; function getCanvasXY(e) { const r = eqMapCanvas.getBoundingClientRect(); const scaleX = eqMapCanvas.width / r.width; const scaleY = eqMapCanvas.height / r.height; return { px: (e.clientX - r.left) * scaleX, py: (e.clientY - r.top) * scaleY }; } function getEqRect(eq) { const left = (eq.map_x_percent / 100) * eqMapCanvas.width; const top = (eq.map_y_percent / 100) * eqMapCanvas.height; const w = ((eq.map_width_percent || EQ_DEFAULT_W) / 100) * eqMapCanvas.width; const h = ((eq.map_height_percent || EQ_DEFAULT_H) / 100) * eqMapCanvas.height; return { left, top, w, h, right: left + w, bottom: top + h }; } function findEqAtPos(px, py) { for (const eq of equipments) { if (eq.map_x_percent == null || eq.map_y_percent == null) continue; const r = getEqRect(eq); if (px >= r.left - 5 && px <= r.right + 5 && py >= r.top - 12 && py <= r.bottom + 5) return eq; } return null; } // 모서리 근처인지 판별 (8px 범위), 어느 모서리인지 반환 function findResizeCorner(eq, px, py) { const r = getEqRect(eq); const margin = 8; const corners = [ { name: 'nw', cx: r.left, cy: r.top }, { name: 'ne', cx: r.right, cy: r.top }, { name: 'sw', cx: r.left, cy: r.bottom }, { name: 'se', cx: r.right, cy: r.bottom }, ]; for (const c of corners) { if (Math.abs(px - c.cx) <= margin && Math.abs(py - c.cy) <= margin) return c.name; } return null; } function resizeCursor(corner) { const map = { nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize' }; return map[corner] || 'default'; } function initEqMapEvents() { if (!eqMapCanvas) return; eqMapCanvas.onmousedown = function(e) { const { px, py } = getCanvasXY(e); if (eqMapPlacingId) return; const eq = findEqAtPos(px, py); if (eq) { const corner = findResizeCorner(eq, px, py); if (corner) { const r = getEqRect(eq); eqResizing = { eqId: eq.equipment_id, corner, origLeft: r.left, origTop: r.top, origRight: r.right, origBottom: r.bottom }; eqMapCanvas.style.cursor = resizeCursor(corner); e.preventDefault(); return; } // 이동: 좌상단 기준 오프셋 계산 const r = getEqRect(eq); eqDraggingId = eq.equipment_id; eqDragOffsetX = px - r.left; eqDragOffsetY = py - r.top; eqDragMoved = false; eqMapCanvas.style.cursor = 'grabbing'; e.preventDefault(); } }; eqMapCanvas.onmousemove = function(e) { const { px, py } = getCanvasXY(e); // 리사이즈 중 if (eqResizing) { const eq = equipments.find(x => x.equipment_id === eqResizing.eqId); if (!eq) return; let { origLeft, origTop, origRight, origBottom } = eqResizing; const corner = eqResizing.corner; if (corner.includes('n')) origTop = py; if (corner.includes('s')) origBottom = py; if (corner.includes('w')) origLeft = px; if (corner.includes('e')) origRight = px; if (Math.abs(origRight - origLeft) < 10 || Math.abs(origBottom - origTop) < 10) return; const newLeft = Math.min(origLeft, origRight); const newRight = Math.max(origLeft, origRight); const newTop = Math.min(origTop, origBottom); const newBottom = Math.max(origTop, origBottom); // 좌상단 기준 저장 eq._dragX = (newLeft / eqMapCanvas.width * 100); eq._dragY = (newTop / eqMapCanvas.height * 100); eq._dragW = ((newRight - newLeft) / eqMapCanvas.width * 100); eq._dragH = ((newBottom - newTop) / eqMapCanvas.height * 100); drawEqMapEquipments(); return; } // 드래그 이동 중 (좌상단 = 마우스위치 - 오프셋) if (eqDraggingId) { const eq = equipments.find(x => x.equipment_id === eqDraggingId); if (!eq) return; eqDragMoved = true; const newLeft = px - eqDragOffsetX; const newTop = py - eqDragOffsetY; eq._dragX = (newLeft / eqMapCanvas.width * 100); eq._dragY = (newTop / eqMapCanvas.height * 100); drawEqMapEquipments(); return; } // 호버 커서 if (eqMapPlacingId) { eqMapCanvas.style.cursor = 'crosshair'; return; } const eq = findEqAtPos(px, py); if (eq) { const corner = findResizeCorner(eq, px, py); eqMapCanvas.style.cursor = corner ? resizeCursor(corner) : 'grab'; } else { eqMapCanvas.style.cursor = 'default'; } }; eqMapCanvas.onmouseup = function(e) { // 리사이즈 완료 if (eqResizing) { const eq = equipments.find(x => x.equipment_id === eqResizing.eqId); if (eq && eq._dragX != null) { saveEqMapFull(eq.equipment_id, eq._dragX, eq._dragY, eq._dragW, eq._dragH); delete eq._dragX; delete eq._dragY; delete eq._dragW; delete eq._dragH; } eqResizing = null; eqMapCanvas.style.cursor = 'default'; return; } // 드래그 이동 완료 if (eqDraggingId) { const eq = equipments.find(x => x.equipment_id === eqDraggingId); if (eq && eqDragMoved && eq._dragX != null) { saveEqMapFull(eqDraggingId, eq._dragX, eq._dragY, eq.map_width_percent || EQ_DEFAULT_W, eq.map_height_percent || EQ_DEFAULT_H); } if (eq) { delete eq._dragX; delete eq._dragY; } eqDraggingId = null; eqDragMoved = false; eqMapCanvas.style.cursor = 'default'; } }; eqMapCanvas.onmouseleave = function() { if (eqDraggingId || eqResizing) { const id = eqDraggingId || eqResizing?.eqId; const eq = equipments.find(x => x.equipment_id === id); if (eq) { delete eq._dragX; delete eq._dragY; delete eq._dragW; delete eq._dragH; } eqDraggingId = null; eqResizing = null; eqDragMoved = false; drawEqMapEquipments(); } }; eqMapCanvas.onclick = onEqMapClick; } function onEqMapClick(e) { if (!eqMapPlacingId) return; const { px, py } = getCanvasXY(e); // 클릭 위치 = 좌상단 기준, 기본 크기 8x6 (tkfb 동일) const xPct = (px / eqMapCanvas.width * 100).toFixed(2); const yPct = (py / eqMapCanvas.height * 100).toFixed(2); const eq = equipments.find(x => x.equipment_id === eqMapPlacingId); saveEqMapFull(eqMapPlacingId, parseFloat(xPct), parseFloat(yPct), eq?.map_width_percent || EQ_DEFAULT_W, eq?.map_height_percent || EQ_DEFAULT_H); eqMapPlacingId = null; eqMapCanvas.style.cursor = 'default'; } async function saveEqMapFull(eqId, x, y, w, h) { try { await api(`/equipments/${eqId}/map-position`, { method: 'PATCH', body: JSON.stringify({ map_x_percent: parseFloat(parseFloat(x).toFixed(2)), map_y_percent: parseFloat(parseFloat(y).toFixed(2)), map_width_percent: parseFloat(parseFloat(w).toFixed(2)), map_height_percent: parseFloat(parseFloat(h).toFixed(2)) }) }); showToast('설비 위치가 저장되었습니다.'); await loadEquipments(); } catch(e) { showToast(e.message, 'error'); } } async function saveEqMapPosition(eqId, x, y) { const eq = equipments.find(e => e.equipment_id === eqId); saveEqMapFull(eqId, x, y, eq?.map_width_percent || EQ_DEFAULT_W, eq?.map_height_percent || EQ_DEFAULT_H); } function startPlaceEquipment(eqId) { eqMapPlacingId = eqId; if (eqMapCanvas) eqMapCanvas.style.cursor = 'crosshair'; const eq = equipments.find(x => x.equipment_id === eqId); const placed = eq?.map_x_percent != null; showToast(`"${eq?.equipment_name || '설비'}" ${placed ? '위치 재지정' : '배치'} - 배치도에서 위치를 클릭하세요`); document.getElementById('eqMapCanvasWrap')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } async function uploadWorkplaceLayoutImage() { const file = document.getElementById('wpLayoutImageFile').files[0]; if (!file) return; try { const fd = new FormData(); fd.append('image', file); const token = getToken(); const res = await fetch(`${API_BASE}/workplaces/${selectedWorkplaceId}/layout-image`, { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '' }, body: fd }); const result = await res.json(); if (!res.ok) throw new Error(result.error || '업로드 실패'); showToast('배치도 이미지가 업로드되었습니다.'); const wp = workplaces.find(w => w.workplace_id === selectedWorkplaceId); if (wp) wp.layout_image = result.data.image_path; loadEqMap(); } catch(e) { showToast(e.message || '업로드 실패', 'error'); } } /* ===== Equipment Detail Modal ===== */ async function openEqDetailModal(eqId) { eqDetailEqId = eqId; const eq = equipments.find(e => e.equipment_id === eqId); if (!eq) return; document.getElementById('eqDetailTitle').textContent = `${eq.equipment_code} - ${eq.equipment_name}`; document.getElementById('eqReturnBtn').classList.toggle('hidden', !eq.is_temporarily_moved); const fmt = v => v || '-'; const fmtDate = v => v ? v.substring(0, 10) : '-'; const fmtPrice = v => v ? Number(v).toLocaleString() + '원' : '-'; document.getElementById('eqDetailContent').innerHTML = `
유형: ${fmt(eq.equipment_type)}
상태: ${eqStatusBadge(eq.status)}
제조사: ${fmt(eq.manufacturer)}
모델: ${fmt(eq.model_name)}
공급업체: ${fmt(eq.supplier)}
구매가격: ${fmtPrice(eq.purchase_price)}
설치일: ${fmtDate(eq.installation_date)}
시리얼: ${fmt(eq.serial_number)}
사양: ${fmt(eq.specifications)}
비고: ${fmt(eq.notes)}
`; loadEqPhotos(eqId); document.getElementById('eqDetailModal').classList.remove('hidden'); } function closeEqDetailModal() { document.getElementById('eqDetailModal').classList.add('hidden'); } async function loadEqPhotos(eqId) { const c = document.getElementById('eqPhotoGrid'); try { const r = await api(`/equipments/${eqId}/photos`); const photos = r.data || []; if (!photos.length) { c.innerHTML = '

사진 없음

'; return; } c.innerHTML = photos.map(p => { const fname = (p.photo_path||'').replace(/^\/uploads\//, ''); return `
`; }).join(''); } catch(e) { c.innerHTML = '

로드 실패

'; } } async function uploadEqPhoto() { const file = document.getElementById('eqPhotoFile').files[0]; if (!file || !eqDetailEqId) return; try { const fd = new FormData(); fd.append('photo', file); const token = getToken(); const res = await fetch(`${API_BASE}/equipments/${eqDetailEqId}/photos`, { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '' }, body: fd }); const result = await res.json(); if (!res.ok) throw new Error(result.error || '업로드 실패'); showToast('사진이 추가되었습니다.'); loadEqPhotos(eqDetailEqId); } catch(e) { showToast(e.message, 'error'); } document.getElementById('eqPhotoFile').value = ''; } async function deleteEqPhoto(photoId) { if (!confirm('사진을 삭제하시겠습니까?')) return; try { await api(`/equipments/photos/${photoId}`, { method: 'DELETE' }); showToast('삭제됨'); loadEqPhotos(eqDetailEqId); } catch(e) { showToast(e.message, 'error'); } }