- TBM 팀원 추가 시 중복 배정 검증 및 409 에러 처리 (tbmController, tbmModel, tbm-create.js, tbm.js, tbm/api.js) - tkuser/tkfb 설비 배치도 좌표계를 좌상단 기준으로 통일 (CSS left/top 방식) - tkuser 설비 배치도에 드래그 이동, 코너 리사이즈, 배치 버튼 추가 - 대분류 지도 영역 수정 버튼 추가 (workplace-layout-map.js, tkuser-layout-map.js) - tkfb workplace-status 캔버스 maxWidth 800 통일 - zone-detail.css object-fit:contain 제거 → height:auto로 마커 위치 정확도 개선 - imageUploadService 업로드 경로 Docker 볼륨 마운트 경로로 수정 - repair-management 카테고리 필터 nonconformity → facility 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
731 lines
39 KiB
JavaScript
731 lines
39 KiB
JavaScript
/* ===== 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 ? `<span class="px-1.5 py-0.5 rounded text-xs ${colors[p] || 'bg-gray-50 text-gray-500'}">${p}</span>` : '';
|
|
}
|
|
|
|
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 = '<option value="">선택</option>';
|
|
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 = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
|
|
}
|
|
}
|
|
|
|
function renderSidebar() {
|
|
const c = document.getElementById('wpSidebarContent');
|
|
if (!c) return;
|
|
let html = '';
|
|
if (wpNavLevel === 'categories') {
|
|
// 공장 목록 레벨
|
|
html += '<div class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">공장 선택</div>';
|
|
html += `<button onclick="openAddWorkplaceModal()" class="w-full px-3 py-2 mb-3 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium flex items-center justify-center gap-1.5"><i class="fas fa-plus text-xs"></i>작업장 추가</button>`;
|
|
if (!workplaceCategories.length) {
|
|
html += '<p class="text-gray-400 text-center py-4 text-sm">등록된 공장이 없습니다.</p>';
|
|
} else {
|
|
html += '<div class="space-y-1.5 flex-1 overflow-y-auto">';
|
|
workplaceCategories.forEach(cat => {
|
|
const count = workplaces.filter(w => w.category_id == cat.category_id).length;
|
|
html += `<div onclick="drillIntoCategory(${cat.category_id},'${(cat.category_name||'').replace(/'/g,"\\'")}')" class="flex items-center justify-between p-2.5 rounded-lg hover:bg-blue-50 transition-colors cursor-pointer bg-gray-50">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<i class="fas fa-industry text-gray-400 text-sm"></i>
|
|
<span class="text-sm font-medium text-gray-800 truncate">${cat.category_name}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5 flex-shrink-0">
|
|
<span class="text-xs text-gray-400">${count}</span>
|
|
<i class="fas fa-chevron-right text-gray-300 text-xs"></i>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
// 미분류 작업장
|
|
const uncategorized = workplaces.filter(w => !w.category_id);
|
|
if (uncategorized.length) {
|
|
html += `<div onclick="drillIntoCategory(0,'미분류')" class="flex items-center justify-between p-2.5 rounded-lg hover:bg-blue-50 transition-colors cursor-pointer bg-gray-50">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<i class="fas fa-folder-open text-gray-300 text-sm"></i>
|
|
<span class="text-sm font-medium text-gray-500 truncate">미분류</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5 flex-shrink-0">
|
|
<span class="text-xs text-gray-400">${uncategorized.length}</span>
|
|
<i class="fas fa-chevron-right text-gray-300 text-xs"></i>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
} else {
|
|
// 작업장 목록 레벨 (특정 공장 내)
|
|
html += `<button onclick="backToCategories()" class="flex items-center gap-1.5 text-sm text-slate-600 hover:text-slate-800 mb-2 px-1 py-1 rounded hover:bg-gray-100 transition-colors"><i class="fas fa-arrow-left text-xs"></i>전체 공장</button>`;
|
|
html += `<div class="text-sm font-semibold text-gray-800 mb-2 px-1"><i class="fas fa-industry text-gray-400 mr-1.5"></i>${wpNavCategoryName}</div>`;
|
|
html += `<button onclick="openAddWorkplaceModal()" class="w-full px-3 py-2 mb-3 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium flex items-center justify-center gap-1.5"><i class="fas fa-plus text-xs"></i>작업장 추가</button>`;
|
|
const filtered = wpNavCategoryId === 0
|
|
? workplaces.filter(w => !w.category_id)
|
|
: workplaces.filter(w => w.category_id == wpNavCategoryId);
|
|
if (!filtered.length) {
|
|
html += '<p class="text-gray-400 text-center py-4 text-sm">등록된 작업장이 없습니다.</p>';
|
|
} else {
|
|
html += '<div class="space-y-1.5 flex-1 overflow-y-auto">';
|
|
filtered.forEach(w => {
|
|
html += `<div class="flex items-center justify-between p-2.5 rounded-lg hover:bg-blue-50 transition-colors cursor-pointer ${selectedWorkplaceId === w.workplace_id ? 'bg-blue-50 ring-1 ring-blue-200' : 'bg-gray-50'}" onclick="selectWorkplaceForEquipments(${w.workplace_id},'${(w.workplace_name||'').replace(/'/g,"\\'")}')">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-building mr-1.5 text-gray-400 text-xs"></i>${w.workplace_name}</div>
|
|
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
|
${purposeBadge(w.workplace_purpose)}
|
|
${w.is_active === 0 || w.is_active === false ? '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : ''}
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
|
<button onclick="event.stopPropagation(); editWorkplace(${w.workplace_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
|
${w.is_active !== 0 && w.is_active !== false ? `<button onclick="event.stopPropagation(); deactivateWorkplace(${w.workplace_id},'${(w.workplace_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
|
|
</div>
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
}
|
|
}
|
|
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 = `<i class="fas fa-map text-slate-400 mr-2"></i>${catName} - 구역지도`;
|
|
selectedMapCategoryId = categoryId;
|
|
if (categoryId === 0) {
|
|
document.getElementById('layoutPreviewArea').classList.remove('hidden');
|
|
document.getElementById('layoutPreviewCanvas').classList.add('hidden');
|
|
document.getElementById('layoutPreviewArea').innerHTML = '<i class="fas fa-info-circle text-2xl mb-2"></i><p>미분류 작업장에는 구역지도가 없습니다.</p>';
|
|
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 `<span class="px-1.5 py-0.5 rounded text-xs ${map[status] || 'bg-gray-100 text-gray-500'}">${labels[status] || status || ''}</span>`;
|
|
}
|
|
|
|
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 = `<div class="text-red-500 text-center py-4"><p class="text-sm">${e.message}</p></div>`;
|
|
}
|
|
}
|
|
|
|
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 = '<p class="text-gray-400 text-center py-4 text-sm">설비가 없습니다.</p>'; return; }
|
|
c.innerHTML = filtered.map(e => {
|
|
const placed = e.map_x_percent != null && e.map_y_percent != null;
|
|
return `
|
|
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" onclick="openEqDetailModal(${e.equipment_id})">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium text-gray-800 truncate">
|
|
<span class="text-xs text-gray-400 font-mono mr-1.5">${e.equipment_code || ''}</span>${e.equipment_name}
|
|
${e.is_temporarily_moved ? '<i class="fas fa-arrows-alt text-blue-400 ml-1" title="임시이동중"></i>' : ''}
|
|
</div>
|
|
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
|
${e.equipment_type ? `<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-600">${e.equipment_type}</span>` : ''}
|
|
${e.manufacturer ? `<span class="text-gray-400">${e.manufacturer}</span>` : ''}
|
|
${eqStatusBadge(e.status)}
|
|
${placed ? '<span class="px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-500 text-[10px]"><i class="fas fa-map-pin"></i> 배치됨</span>' : '<span class="px-1.5 py-0.5 rounded bg-orange-50 text-orange-400 text-[10px]">미배치</span>'}
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
|
<button onclick="event.stopPropagation(); startPlaceEquipment(${e.equipment_id})" class="p-1.5 ${placed ? 'text-emerald-500 hover:text-emerald-700 hover:bg-emerald-100' : 'text-orange-400 hover:text-orange-600 hover:bg-orange-100'} rounded" title="${placed ? '위치 재지정' : '배치도에 배치'}"><i class="fas fa-map-pin text-xs"></i></button>
|
|
<button onclick="event.stopPropagation(); editEquipment(${e.equipment_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
|
<button onclick="event.stopPropagation(); deleteEquipment(${e.equipment_id},'${(e.equipment_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button>
|
|
</div>
|
|
</div>`;
|
|
}).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 = '<option value="">전체 유형</option>';
|
|
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 = `
|
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
|
<div><span class="text-gray-400">유형:</span> ${fmt(eq.equipment_type)}</div>
|
|
<div><span class="text-gray-400">상태:</span> ${eqStatusBadge(eq.status)}</div>
|
|
<div><span class="text-gray-400">제조사:</span> ${fmt(eq.manufacturer)}</div>
|
|
<div><span class="text-gray-400">모델:</span> ${fmt(eq.model_name)}</div>
|
|
<div><span class="text-gray-400">공급업체:</span> ${fmt(eq.supplier)}</div>
|
|
<div><span class="text-gray-400">구매가격:</span> ${fmtPrice(eq.purchase_price)}</div>
|
|
<div><span class="text-gray-400">설치일:</span> ${fmtDate(eq.installation_date)}</div>
|
|
<div><span class="text-gray-400">시리얼:</span> ${fmt(eq.serial_number)}</div>
|
|
<div class="col-span-2"><span class="text-gray-400">사양:</span> ${fmt(eq.specifications)}</div>
|
|
<div class="col-span-2"><span class="text-gray-400">비고:</span> ${fmt(eq.notes)}</div>
|
|
</div>
|
|
<div class="mt-2"><button onclick="startPlaceEquipment(${eq.equipment_id}); closeEqDetailModal();" class="text-xs text-blue-600 hover:underline"><i class="fas fa-map-pin mr-1"></i>배치도에 위치 지정</button></div>`;
|
|
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 = '<p class="text-gray-400 text-xs col-span-4 text-center py-2">사진 없음</p>'; return; }
|
|
c.innerHTML = photos.map(p => {
|
|
const fname = (p.photo_path||'').replace(/^\/uploads\//, '');
|
|
return `
|
|
<div class="relative group cursor-pointer" onclick="document.getElementById('photoViewImage').src='/uploads/${fname}'; document.getElementById('photoViewModal').classList.remove('hidden');">
|
|
<img src="/uploads/${fname}" class="w-full h-20 object-cover rounded">
|
|
<button onclick="event.stopPropagation(); deleteEqPhoto(${p.photo_id})" class="absolute top-0.5 right-0.5 bg-red-500 text-white rounded-full w-4 h-4 text-[10px] leading-4 text-center opacity-0 group-hover:opacity-100">×</button>
|
|
</div>`; }).join('');
|
|
} catch(e) { c.innerHTML = '<p class="text-red-400 text-xs col-span-4">로드 실패</p>'; }
|
|
}
|
|
|
|
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'); }
|
|
}
|