feat: 설비 관리 시스템 구축

## 주요 기능
- 설비 등록/수정/삭제 기능
- 작업장별 설비 연결
- 작업장 지도에서 설비 위치 정의
- 필터링 및 검색 기능

## 백엔드
- equipments 테이블 생성 (마이그레이션)
- 설비 API (모델, 컨트롤러, 라우트) 구현
- workplaces 테이블에 layout_image 컬럼 추가

## 프론트엔드
- 설비 관리 페이지 (equipments.html)
- 설비 관리 JavaScript (equipment-management.js)
- 작업장 지도 모달 개선

## 버그 수정
- 카테고리/작업장 이미지 보존 로직 개선 (null 처리)
- 작업장 레이아웃 이미지 업로드 경로 수정 (public/uploads → uploads)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-28 09:22:57 +09:00
parent 9c98c44d8a
commit e1227a69fe
13 changed files with 2710 additions and 22 deletions

View File

@@ -0,0 +1,344 @@
// equipment-management.js
// 설비 관리 페이지 JavaScript
let equipments = [];
let workplaces = [];
let equipmentTypes = [];
let currentEquipment = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await loadInitialData();
});
// 초기 데이터 로드
async function loadInitialData() {
try {
await Promise.all([
loadEquipments(),
loadWorkplaces(),
loadEquipmentTypes()
]);
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
alert('데이터를 불러오는데 실패했습니다.');
}
}
// 설비 목록 로드
async function loadEquipments() {
try {
const response = await axios.get('/api/equipments');
if (response.data.success) {
equipments = response.data.data;
renderEquipmentList();
}
} catch (error) {
console.error('설비 목록 로드 실패:', error);
throw error;
}
}
// 작업장 목록 로드
async function loadWorkplaces() {
try {
const response = await axios.get('/api/workplaces');
if (response.data.success) {
workplaces = response.data.data;
populateWorkplaceFilters();
}
} catch (error) {
console.error('작업장 목록 로드 실패:', error);
throw error;
}
}
// 설비 유형 목록 로드
async function loadEquipmentTypes() {
try {
const response = await axios.get('/api/equipments/types');
if (response.data.success) {
equipmentTypes = response.data.data;
populateTypeFilter();
}
} catch (error) {
console.error('설비 유형 로드 실패:', error);
// 실패해도 계속 진행 (유형이 없을 수 있음)
}
}
// 작업장 필터 채우기
function populateWorkplaceFilters() {
const filterWorkplace = document.getElementById('filterWorkplace');
const modalWorkplace = document.getElementById('workplaceId');
const workplaceOptions = workplaces.map(w =>
`<option value="${w.workplace_id}">${w.category_name} - ${w.workplace_name}</option>`
).join('');
filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions;
modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions;
}
// 설비 유형 필터 채우기
function populateTypeFilter() {
const filterType = document.getElementById('filterType');
const typeOptions = equipmentTypes.map(type =>
`<option value="${type}">${type}</option>`
).join('');
filterType.innerHTML = '<option value="">전체</option>' + typeOptions;
}
// 설비 목록 렌더링
function renderEquipmentList() {
const container = document.getElementById('equipmentList');
if (equipments.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>등록된 설비가 없습니다.</p>
<button class="btn btn-primary" onclick="openEquipmentModal()">설비 추가하기</button>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>설비 코드</th>
<th>설비명</th>
<th>유형</th>
<th>작업장</th>
<th>제조사</th>
<th>모델명</th>
<th>상태</th>
<th>관리</th>
</tr>
</thead>
<tbody>
${equipments.map(equipment => `
<tr>
<td><strong>${equipment.equipment_code}</strong></td>
<td>${equipment.equipment_name}</td>
<td>${equipment.equipment_type || '-'}</td>
<td>${equipment.workplace_name || '-'}</td>
<td>${equipment.manufacturer || '-'}</td>
<td>${equipment.model_name || '-'}</td>
<td>
<span class="status-badge status-${equipment.status}">
${getStatusText(equipment.status)}
</span>
</td>
<td>
<div class="action-buttons">
<button class="btn-small btn-primary" onclick="editEquipment(${equipment.equipment_id})" title="수정">
✏️
</button>
<button class="btn-small btn-danger" onclick="deleteEquipment(${equipment.equipment_id})" title="삭제">
🗑️
</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// 상태 텍스트 변환
function getStatusText(status) {
const statusMap = {
'active': '활성',
'maintenance': '정비중',
'inactive': '비활성'
};
return statusMap[status] || status;
}
// 필터링
function filterEquipments() {
const workplaceFilter = document.getElementById('filterWorkplace').value;
const typeFilter = document.getElementById('filterType').value;
const statusFilter = document.getElementById('filterStatus').value;
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
// API에서 필터링된 데이터를 가져오는 것이 더 효율적이지만,
// 클라이언트 측에서도 필터링을 적용합니다.
let filtered = [...equipments];
if (workplaceFilter) {
filtered = filtered.filter(e => e.workplace_id == workplaceFilter);
}
if (typeFilter) {
filtered = filtered.filter(e => e.equipment_type === typeFilter);
}
if (statusFilter) {
filtered = filtered.filter(e => e.status === statusFilter);
}
if (searchTerm) {
filtered = filtered.filter(e =>
e.equipment_name.toLowerCase().includes(searchTerm) ||
e.equipment_code.toLowerCase().includes(searchTerm)
);
}
// 임시로 equipments를 필터링된 것으로 교체하고 렌더링
const originalEquipments = equipments;
equipments = filtered;
renderEquipmentList();
equipments = originalEquipments;
}
// 설비 추가 모달 열기
function openEquipmentModal(equipmentId = null) {
currentEquipment = equipmentId;
const modal = document.getElementById('equipmentModal');
const modalTitle = document.getElementById('modalTitle');
const form = document.getElementById('equipmentForm');
form.reset();
document.getElementById('equipmentId').value = '';
if (equipmentId) {
modalTitle.textContent = '설비 수정';
loadEquipmentData(equipmentId);
} else {
modalTitle.textContent = '설비 추가';
}
modal.style.display = 'flex';
}
// 설비 데이터 로드 (수정용)
async function loadEquipmentData(equipmentId) {
try {
const response = await axios.get(`/api/equipments/${equipmentId}`);
if (response.data.success) {
const equipment = response.data.data;
document.getElementById('equipmentId').value = equipment.equipment_id;
document.getElementById('equipmentCode').value = equipment.equipment_code;
document.getElementById('equipmentName').value = equipment.equipment_name;
document.getElementById('equipmentType').value = equipment.equipment_type || '';
document.getElementById('workplaceId').value = equipment.workplace_id || '';
document.getElementById('manufacturer').value = equipment.manufacturer || '';
document.getElementById('modelName').value = equipment.model_name || '';
document.getElementById('serialNumber').value = equipment.serial_number || '';
document.getElementById('installationDate').value = equipment.installation_date ? equipment.installation_date.split('T')[0] : '';
document.getElementById('equipmentStatus').value = equipment.status || 'active';
document.getElementById('specifications').value = equipment.specifications || '';
document.getElementById('notes').value = equipment.notes || '';
}
} catch (error) {
console.error('설비 데이터 로드 실패:', error);
alert('설비 정보를 불러오는데 실패했습니다.');
}
}
// 설비 모달 닫기
function closeEquipmentModal() {
document.getElementById('equipmentModal').style.display = 'none';
currentEquipment = null;
}
// 설비 저장
async function saveEquipment() {
const equipmentId = document.getElementById('equipmentId').value;
const equipmentData = {
equipment_code: document.getElementById('equipmentCode').value.trim(),
equipment_name: document.getElementById('equipmentName').value.trim(),
equipment_type: document.getElementById('equipmentType').value.trim() || null,
workplace_id: document.getElementById('workplaceId').value || null,
manufacturer: document.getElementById('manufacturer').value.trim() || null,
model_name: document.getElementById('modelName').value.trim() || null,
serial_number: document.getElementById('serialNumber').value.trim() || null,
installation_date: document.getElementById('installationDate').value || null,
status: document.getElementById('equipmentStatus').value,
specifications: document.getElementById('specifications').value.trim() || null,
notes: document.getElementById('notes').value.trim() || null
};
// 유효성 검사
if (!equipmentData.equipment_code) {
alert('설비 코드를 입력해주세요.');
return;
}
if (!equipmentData.equipment_name) {
alert('설비명을 입력해주세요.');
return;
}
try {
let response;
if (equipmentId) {
// 수정
response = await axios.put(`/api/equipments/${equipmentId}`, equipmentData);
} else {
// 추가
response = await axios.post('/api/equipments', equipmentData);
}
if (response.data.success) {
alert(equipmentId ? '설비가 수정되었습니다.' : '설비가 추가되었습니다.');
closeEquipmentModal();
await loadEquipments();
await loadEquipmentTypes(); // 새로운 유형이 추가될 수 있으므로
}
} catch (error) {
console.error('설비 저장 실패:', error);
if (error.response && error.response.data && error.response.data.message) {
alert(error.response.data.message);
} else {
alert('설비 저장 중 오류가 발생했습니다.');
}
}
}
// 설비 수정
function editEquipment(equipmentId) {
openEquipmentModal(equipmentId);
}
// 설비 삭제
async function deleteEquipment(equipmentId) {
const equipment = equipments.find(e => e.equipment_id === equipmentId);
if (!equipment) return;
if (!confirm(`'${equipment.equipment_name}' 설비를 삭제하시겠습니까?`)) {
return;
}
try {
const response = await axios.delete(`/api/equipments/${equipmentId}`);
if (response.data.success) {
alert('설비가 삭제되었습니다.');
await loadEquipments();
}
} catch (error) {
console.error('설비 삭제 실패:', error);
alert('설비 삭제 중 오류가 발생했습니다.');
}
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeEquipmentModal();
}
});
// 모달 외부 클릭 시 닫기
document.getElementById('equipmentModal').addEventListener('click', (e) => {
if (e.target.id === 'equipmentModal') {
closeEquipmentModal();
}
});

View File

@@ -36,7 +36,7 @@ async function loadAllData() {
// 카테고리 목록 로드
async function loadCategories() {
try {
const response = await apiCall('/workplaces/categories', 'GET');
const response = await window.apiCall('/workplaces/categories', 'GET');
let categoryData = [];
if (response && response.success && Array.isArray(response.data)) {
@@ -91,6 +91,241 @@ function switchCategory(categoryId) {
currentCategoryId = categoryId === '' ? '' : categoryId;
renderCategoryTabs();
renderWorkplaces();
// 레이아웃 지도 섹션 표시/숨김
const layoutMapSection = document.getElementById('layoutMapSection');
const selectedCategoryName = document.getElementById('selectedCategoryName');
if (currentCategoryId && layoutMapSection) {
const category = categories.find(c => c.category_id == currentCategoryId);
if (category) {
layoutMapSection.style.display = 'block';
if (selectedCategoryName) {
selectedCategoryName.textContent = category.category_name;
}
// 레이아웃 미리보기 업데이트
updateLayoutPreview(category);
}
} else if (layoutMapSection) {
layoutMapSection.style.display = 'none';
}
}
// 레이아웃 미리보기 업데이트
async function updateLayoutPreview(category) {
const previewDiv = document.getElementById('layoutMapPreview');
if (!previewDiv) return;
if (category.layout_image) {
// 이미지 경로를 전체 URL로 변환
const fullImageUrl = category.layout_image.startsWith('http')
? category.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
// Canvas 컨테이너 생성
previewDiv.innerHTML = `
<div style="text-align: center;">
<canvas id="previewCanvas" style="max-width: 100%; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); cursor: default;"></canvas>
<p style="color: #64748b; margin-top: 12px; font-size: 14px;">
클릭하여 작업장 영역을 수정하려면 "지도 설정" 버튼을 누르세요
</p>
</div>
`;
// 이미지와 영역 로드
await loadImageWithRegions(fullImageUrl, category.category_id);
} else {
previewDiv.innerHTML = `
<div style="padding: 40px;">
<span style="font-size: 48px;">🗺️</span>
<p style="color: #94a3b8; margin-top: 16px;">
이 공장의 레이아웃 이미지가 아직 등록되지 않았습니다
</p>
<p style="color: #cbd5e1; font-size: 14px; margin-top: 8px;">
"지도 설정" 버튼을 눌러 레이아웃 이미지를 업로드하고 작업장 위치를 지정하세요
</p>
</div>
`;
}
}
// 이미지와 영역을 캔버스에 로드
async function loadImageWithRegions(imageUrl, categoryId) {
const canvas = document.getElementById('previewCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = async function() {
// 최대 너비 설정 (반응형)
const maxWidth = 800;
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
// 이미지 그리기
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 영역 데이터 로드 및 그리기
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`, 'GET');
let regions = [];
if (response && response.success && Array.isArray(response.data)) {
regions = response.data;
} else if (Array.isArray(response)) {
regions = response;
}
// 각 영역 그리기
regions.forEach(region => {
// 퍼센트를 픽셀로 변환
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
const width = x2 - x1;
const height = y2 - y1;
// 영역 테두리 그리기
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x1, y1, width, height);
// 영역 배경 (반투명)
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
ctx.fillRect(x1, y1, width, height);
// 작업장 이름 표시
if (region.workplace_name) {
ctx.fillStyle = '#10b981';
ctx.font = 'bold 14px sans-serif';
// 텍스트 배경
const textMetrics = ctx.measureText(region.workplace_name);
const textPadding = 4;
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(x1 + 5, y1 + 5, textMetrics.width + textPadding * 2, 20);
// 텍스트
ctx.fillStyle = '#10b981';
ctx.fillText(region.workplace_name, x1 + 5 + textPadding, y1 + 20);
}
});
if (regions.length > 0) {
console.log(`✅ 레이아웃 미리보기에 ${regions.length}개 영역 표시 완료`);
}
} catch (error) {
console.error('영역 로드 오류:', error);
}
};
img.onerror = function() {
console.error('이미지 로드 실패:', imageUrl);
};
img.src = imageUrl;
}
// 작업장 카드에 지도 썸네일 로드
async function loadWorkplaceMapThumbnail(workplace) {
const thumbnailDiv = document.getElementById(`workplace-map-${workplace.workplace_id}`);
if (!thumbnailDiv) return;
// 작업장 자체에 레이아웃 이미지가 있는 경우
if (workplace.layout_image) {
const fullImageUrl = workplace.layout_image.startsWith('http')
? workplace.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 8px; background: #f0f9ff; border-radius: 6px; border: 1px solid #bae6fd;">
<div style="font-size: 12px; color: #0369a1; margin-bottom: 6px; font-weight: 500;">📍 작업장 레이아웃</div>
<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
</div>
`;
return;
}
// 작업장에 이미지가 없으면 카테고리 지도의 영역 표시
try {
// 해당 작업장의 지도 영역 정보 가져오기
const response = await window.apiCall(`/workplaces/map-regions/workplace/${workplace.workplace_id}`, 'GET');
if (!response || (!response.success && !response.region_id)) {
return; // 영역이 정의되지 않은 경우 아무것도 표시하지 않음
}
const region = response.success ? response.data : response;
// 카테고리 정보에서 레이아웃 이미지 가져오기
const category = categories.find(c => c.category_id === workplace.category_id);
if (!category || !category.layout_image) return;
const fullImageUrl = category.layout_image.startsWith('http')
? category.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
// 캔버스 생성
const canvasId = `thumbnail-canvas-${workplace.workplace_id}`;
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 8px; background: #f9fafb; border-radius: 6px; border: 1px solid #e5e7eb;">
<div style="font-size: 12px; color: #64748b; margin-bottom: 6px; font-weight: 500;">📍 지도 위치</div>
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"></canvas>
</div>
`;
// 이미지 로드
const img = new Image();
img.onload = function() {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
// 원본 이미지에서 영역 좌표 계산 (퍼센트를 픽셀로)
const x1 = (region.x_start / 100) * img.width;
const y1 = (region.y_start / 100) * img.height;
const x2 = (region.x_end / 100) * img.width;
const y2 = (region.y_end / 100) * img.height;
const regionWidth = x2 - x1;
const regionHeight = y2 - y1;
// 썸네일 크기 설정 (최대 너비 300px)
const maxThumbWidth = 300;
const scale = regionWidth > maxThumbWidth ? maxThumbWidth / regionWidth : 1;
canvas.width = regionWidth * scale;
canvas.height = regionHeight * scale;
// 영역만 잘라서 그리기
ctx.drawImage(
img,
x1, y1, regionWidth, regionHeight, // 원본에서 잘라낼 영역
0, 0, canvas.width, canvas.height // 캔버스에 그릴 위치와 크기
);
// 테두리 그리기
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 3;
ctx.strokeRect(0, 0, canvas.width, canvas.height);
};
img.onerror = function() {
thumbnailDiv.innerHTML = '';
};
img.src = fullImageUrl;
} catch (error) {
// 오류 시 조용히 처리 (지도가 없는 작업장도 많을 수 있으므로)
console.debug(`작업장 ${workplace.workplace_id}의 지도 영역 없음`);
}
}
// 카테고리 모달 열기
@@ -161,10 +396,10 @@ async function saveCategory() {
let response;
if (categoryId) {
// 수정
response = await apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
} else {
// 신규 등록
response = await apiCall('/workplaces/categories', 'POST', categoryData);
response = await window.apiCall('/workplaces/categories', 'POST', categoryData);
}
if (response && (response.success || response.category_id)) {
@@ -191,7 +426,7 @@ async function deleteCategory() {
}
try {
const response = await apiCall(`/workplaces/categories/${currentEditingCategory.category_id}`, 'DELETE');
const response = await window.apiCall(`/workplaces/categories/${currentEditingCategory.category_id}`, 'DELETE');
if (response && response.success) {
showToast('공장이 성공적으로 삭제되었습니다.', 'success');
@@ -212,7 +447,7 @@ async function deleteCategory() {
// 작업장 목록 로드
async function loadWorkplaces() {
try {
const response = await apiCall('/workplaces', 'GET');
const response = await window.apiCall('/workplaces', 'GET');
let workplaceData = [];
if (response && response.success && Array.isArray(response.data)) {
@@ -259,15 +494,32 @@ function renderWorkplaces() {
const categoryName = workplace.category_name || '미분류';
const isActive = workplace.is_active === 1 || workplace.is_active === true;
// 작업장 용도 아이콘 매핑
const purposeIcons = {
'작업구역': '🔧',
'설비': '⚙️',
'휴게시설': '☕',
'회의실': '💼',
'창고': '📦',
'기타': '📍'
};
const purposeIcon = workplace.workplace_purpose ? purposeIcons[workplace.workplace_purpose] || '📍' : '🏗️';
gridHtml += `
<div class="code-card workplace-card ${isActive ? '' : 'inactive'}" onclick="editWorkplace(${workplace.workplace_id})">
<div class="code-header">
<div class="code-icon" style="background: #dbeafe;">🏗️</div>
<div class="code-icon" style="background: #dbeafe;">${purposeIcon}</div>
<div class="code-info">
<h3 class="code-name">${workplace.workplace_name}</h3>
${workplace.category_id ? `<span class="code-label">🏭 ${categoryName}</span>` : ''}
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px;">
${workplace.category_id ? `<span class="code-label">🏭 ${categoryName}</span>` : ''}
${workplace.workplace_purpose ? `<span class="code-label" style="background: #f3e8ff; color: #7c3aed;">${workplace.workplace_purpose}</span>` : ''}
</div>
</div>
<div class="code-actions">
<button class="btn-small btn-primary" onclick="event.stopPropagation(); openWorkplaceMapModal(${workplace.workplace_id})" title="지도 관리" style="font-size: 14px;">
🗺️
</button>
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editWorkplace(${workplace.workplace_id})" title="수정">
✏️
</button>
@@ -277,6 +529,7 @@ function renderWorkplaces() {
</div>
</div>
${workplace.description ? `<p class="code-description">${workplace.description}</p>` : ''}
<div id="workplace-map-${workplace.workplace_id}" style="margin: 12px 0;"></div>
<div class="code-meta">
<span class="code-date">등록: ${formatDate(workplace.created_at)}</span>
${workplace.updated_at !== workplace.created_at ? `<span class="code-date">수정: ${formatDate(workplace.updated_at)}</span>` : ''}
@@ -286,6 +539,13 @@ function renderWorkplaces() {
});
grid.innerHTML = gridHtml;
// 각 작업장의 지도 미리보기 로드
filtered.forEach(workplace => {
if (workplace.category_id) {
loadWorkplaceMapThumbnail(workplace);
}
});
}
// 작업장 모달 열기
@@ -314,6 +574,8 @@ function openWorkplaceModal(workplaceData = null) {
document.getElementById('workplaceId').value = workplaceData.workplace_id;
document.getElementById('workplaceCategoryId').value = workplaceData.category_id || '';
document.getElementById('workplaceName').value = workplaceData.workplace_name || '';
document.getElementById('workplacePurpose').value = workplaceData.workplace_purpose || '';
document.getElementById('displayPriority').value = workplaceData.display_priority || 0;
document.getElementById('workplaceDescription').value = workplaceData.description || '';
} else {
// 신규 등록 모드
@@ -365,6 +627,8 @@ async function saveWorkplace() {
const workplaceData = {
category_id: document.getElementById('workplaceCategoryId').value || null,
workplace_name: document.getElementById('workplaceName').value.trim(),
workplace_purpose: document.getElementById('workplacePurpose').value || null,
display_priority: parseInt(document.getElementById('displayPriority').value) || 0,
description: document.getElementById('workplaceDescription').value.trim() || null,
is_active: true
};
@@ -379,10 +643,10 @@ async function saveWorkplace() {
let response;
if (workplaceId) {
// 수정
response = await apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
response = await window.apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
} else {
// 신규 등록
response = await apiCall('/workplaces', 'POST', workplaceData);
response = await window.apiCall('/workplaces', 'POST', workplaceData);
}
if (response && (response.success || response.workplace_id)) {
@@ -424,7 +688,7 @@ function deleteWorkplace() {
// 작업장 삭제 실행
async function deleteWorkplaceById(workplaceId) {
try {
const response = await apiCall(`/workplaces/${workplaceId}`, 'DELETE');
const response = await window.apiCall(`/workplaces/${workplaceId}`, 'DELETE');
if (response && response.success) {
showToast('작업장이 성공적으로 삭제되었습니다.', 'success');
@@ -536,7 +800,346 @@ function showToast(message, type = 'info') {
}, 3000);
}
// 전역 함수로 노출
// 전역 함수 및 변수로 노출 (다른 모듈에서 접근 가능하도록)
// getter/setter를 사용하여 항상 최신 값을 반환
Object.defineProperty(window, 'categories', {
get: function() {
return categories;
}
});
Object.defineProperty(window, 'workplaces', {
get: function() {
return workplaces;
}
});
Object.defineProperty(window, 'currentCategoryId', {
get: function() {
return currentCategoryId;
},
set: function(value) {
currentCategoryId = value;
}
});
// ==================== 작업장 지도 관리 ====================
// 작업장 지도 관련 전역 변수
let workplaceCanvas = null;
let workplaceCtx = null;
let workplaceImage = null;
let workplaceIsDrawing = false;
let workplaceStartX = 0;
let workplaceStartY = 0;
let workplaceCurrentRect = null;
let workplaceEquipmentRegions = [];
// 작업장 지도 모달 열기
async function openWorkplaceMapModal(workplaceId) {
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
if (!workplace) {
showToast('작업장 정보를 찾을 수 없습니다.', 'error');
return;
}
// 작업장 이름 설정
const modalTitle = document.getElementById('workplaceMapModalTitle');
if (modalTitle) {
modalTitle.textContent = `${workplace.workplace_name} - 지도 관리`;
}
// 현재 작업장 ID 저장
window.currentWorkplaceMapId = workplaceId;
// 레이아웃 이미지 미리보기 영역 초기화
const preview = document.getElementById('workplaceLayoutPreview');
if (preview && workplace.layout_image) {
const fullImageUrl = workplace.layout_image.startsWith('http')
? workplace.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
preview.innerHTML = `<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px;">`;
// 캔버스 초기화
initWorkplaceCanvas(fullImageUrl);
} else if (preview) {
preview.innerHTML = '<p style="color: #64748b; text-align: center; padding: 20px;">레이아웃 이미지를 업로드해주세요</p>';
}
// 설비 영역 목록 로드 (TODO: API 연동)
workplaceEquipmentRegions = [];
renderWorkplaceEquipmentList();
// 모달 표시
const modal = document.getElementById('workplaceMapModal');
if (modal) {
modal.style.display = 'flex';
}
}
// 작업장 지도 모달 닫기
function closeWorkplaceMapModal() {
const modal = document.getElementById('workplaceMapModal');
if (modal) {
modal.style.display = 'none';
}
window.currentWorkplaceMapId = null;
}
// 작업장 레이아웃 이미지 업로드
async function uploadWorkplaceLayout() {
const fileInput = document.getElementById('workplaceLayoutFile');
if (!fileInput || !fileInput.files || !fileInput.files[0]) {
showToast('파일을 선택해주세요.', 'warning');
return;
}
if (!window.currentWorkplaceMapId) {
showToast('작업장 정보를 찾을 수 없습니다.', 'error');
return;
}
const formData = new FormData();
formData.append('image', fileInput.files[0]);
try {
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:20005/api'}/workplaces/${window.currentWorkplaceMapId}/layout-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
const result = await response.json();
if (result.success) {
showToast('레이아웃 이미지가 업로드되었습니다.', 'success');
// 작업장 목록 새로고침
await loadWorkplaces();
renderWorkplaces();
// 미리보기 업데이트
const preview = document.getElementById('workplaceLayoutPreview');
if (preview && result.data.image_path) {
const fullImageUrl = result.data.image_path.startsWith('http')
? result.data.image_path
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${result.data.image_path}`.replace('/api/', '/');
preview.innerHTML = `<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px;">`;
}
// 파일 입력 초기화
fileInput.value = '';
} else {
showToast(result.message || '업로드 실패', 'error');
}
} catch (error) {
console.error('레이아웃 이미지 업로드 오류:', error);
showToast('레이아웃 이미지 업로드 중 오류가 발생했습니다.', 'error');
}
}
// 작업장 캔버스 초기화
function initWorkplaceCanvas(imageUrl) {
const img = new Image();
img.onload = function() {
workplaceImage = img;
workplaceCanvas = document.getElementById('workplaceRegionCanvas');
if (!workplaceCanvas) return;
workplaceCtx = workplaceCanvas.getContext('2d');
// 캔버스 크기 설정 (최대 800px 너비)
const maxWidth = 800;
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
workplaceCanvas.width = img.width * scale;
workplaceCanvas.height = img.height * scale;
// 이미지 그리기
workplaceCtx.drawImage(img, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
// 기존 영역들 표시
drawWorkplaceRegions();
// 이벤트 리스너 등록
workplaceCanvas.onmousedown = startWorkplaceDraw;
workplaceCanvas.onmousemove = drawWorkplace;
workplaceCanvas.onmouseup = endWorkplaceDraw;
};
img.src = imageUrl;
}
// 드래그 시작
function startWorkplaceDraw(e) {
workplaceIsDrawing = true;
const rect = workplaceCanvas.getBoundingClientRect();
workplaceStartX = e.clientX - rect.left;
workplaceStartY = e.clientY - rect.top;
}
// 드래그 중
function drawWorkplace(e) {
if (!workplaceIsDrawing) return;
const rect = workplaceCanvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
// 캔버스 초기화 및 이미지 다시 그리기
workplaceCtx.drawImage(workplaceImage, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
// 기존 영역들 표시
drawWorkplaceRegions();
// 현재 그리는 사각형
workplaceCtx.strokeStyle = '#3b82f6';
workplaceCtx.lineWidth = 3;
workplaceCtx.strokeRect(
workplaceStartX,
workplaceStartY,
currentX - workplaceStartX,
currentY - workplaceStartY
);
workplaceCurrentRect = {
x: workplaceStartX,
y: workplaceStartY,
width: currentX - workplaceStartX,
height: currentY - workplaceStartY
};
}
// 드래그 종료
function endWorkplaceDraw(e) {
workplaceIsDrawing = false;
}
// 기존 영역들 그리기
function drawWorkplaceRegions() {
workplaceEquipmentRegions.forEach((region, index) => {
workplaceCtx.strokeStyle = '#10b981';
workplaceCtx.lineWidth = 2;
workplaceCtx.strokeRect(region.x, region.y, region.width, region.height);
// 영역 이름 표시
workplaceCtx.fillStyle = '#10b981';
workplaceCtx.font = '14px sans-serif';
workplaceCtx.fillText(region.equipment_name, region.x + 5, region.y + 20);
});
}
// 현재 영역 지우기
function clearWorkplaceCurrentRegion() {
workplaceCurrentRect = null;
if (workplaceCanvas && workplaceImage) {
workplaceCtx.drawImage(workplaceImage, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
drawWorkplaceRegions();
}
}
// 설비 위치 저장
function saveWorkplaceEquipmentRegion() {
const equipmentName = document.getElementById('equipmentNameInput');
if (!equipmentName || !equipmentName.value.trim()) {
showToast('설비 이름을 입력해주세요.', 'warning');
return;
}
if (!workplaceCurrentRect) {
showToast('영역을 드래그하여 선택해주세요.', 'warning');
return;
}
// 퍼센트로 변환
const xPercent = (workplaceCurrentRect.x / workplaceCanvas.width) * 100;
const yPercent = (workplaceCurrentRect.y / workplaceCanvas.height) * 100;
const widthPercent = (workplaceCurrentRect.width / workplaceCanvas.width) * 100;
const heightPercent = (workplaceCurrentRect.height / workplaceCanvas.height) * 100;
const newRegion = {
equipment_name: equipmentName.value.trim(),
x: workplaceCurrentRect.x,
y: workplaceCurrentRect.y,
width: workplaceCurrentRect.width,
height: workplaceCurrentRect.height,
x_percent: xPercent,
y_percent: yPercent,
width_percent: widthPercent,
height_percent: heightPercent
};
workplaceEquipmentRegions.push(newRegion);
// UI 업데이트
renderWorkplaceEquipmentList();
clearWorkplaceCurrentRegion();
equipmentName.value = '';
showToast(`설비 "${newRegion.equipment_name}" 위치가 저장되었습니다.`, 'success');
}
// 설비 목록 렌더링
function renderWorkplaceEquipmentList() {
const listDiv = document.getElementById('workplaceEquipmentList');
if (!listDiv) return;
if (workplaceEquipmentRegions.length === 0) {
listDiv.innerHTML = '<p style="color: #94a3b8; text-align: center;">아직 정의된 설비가 없습니다</p>';
return;
}
let html = '';
workplaceEquipmentRegions.forEach((region, index) => {
html += `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px; background: white; border-radius: 6px; margin-bottom: 8px;">
<div>
<strong style="color: #1e293b;">${region.equipment_name}</strong>
<span style="color: #64748b; font-size: 12px; margin-left: 8px;">
(${region.x_percent.toFixed(1)}%, ${region.y_percent.toFixed(1)}%)
</span>
</div>
<button class="btn-small btn-delete" onclick="removeWorkplaceEquipmentRegion(${index})" title="삭제">
🗑️
</button>
</div>
`;
});
listDiv.innerHTML = html;
}
// 설비 영역 삭제
function removeWorkplaceEquipmentRegion(index) {
workplaceEquipmentRegions.splice(index, 1);
renderWorkplaceEquipmentList();
// 캔버스 다시 그리기
if (workplaceCanvas && workplaceImage) {
workplaceCtx.drawImage(workplaceImage, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
drawWorkplaceRegions();
}
showToast('설비 위치가 삭제되었습니다.', 'success');
}
// 작업장 레이아웃 이미지 미리보기
function previewWorkplaceLayoutImage(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
const preview = document.getElementById('workplaceLayoutPreview');
if (preview) {
preview.innerHTML = `<img src="${e.target.result}" alt="미리보기" style="max-width: 100%; border-radius: 4px;">`;
}
};
reader.readAsDataURL(file);
}
}
window.switchCategory = switchCategory;
window.openCategoryModal = openCategoryModal;
window.closeCategoryModal = closeCategoryModal;
@@ -549,3 +1152,13 @@ window.saveWorkplace = saveWorkplace;
window.deleteWorkplace = deleteWorkplace;
window.confirmDeleteWorkplace = confirmDeleteWorkplace;
window.refreshWorkplaces = refreshWorkplaces;
window.showToast = showToast;
window.loadCategories = loadCategories;
window.updateLayoutPreview = updateLayoutPreview;
window.openWorkplaceMapModal = openWorkplaceMapModal;
window.closeWorkplaceMapModal = closeWorkplaceMapModal;
window.uploadWorkplaceLayout = uploadWorkplaceLayout;
window.clearWorkplaceCurrentRegion = clearWorkplaceCurrentRegion;
window.saveWorkplaceEquipmentRegion = saveWorkplaceEquipmentRegion;
window.removeWorkplaceEquipmentRegion = removeWorkplaceEquipmentRegion;
window.previewWorkplaceLayoutImage = previewWorkplaceLayoutImage;

View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설비 관리 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
<div class="page-container">
<!-- 사이드바 -->
<aside class="sidebar">
<nav class="sidebar-nav">
<div class="sidebar-header">
<h3 class="sidebar-title">관리 메뉴</h3>
</div>
<ul class="sidebar-menu">
<li class="menu-item">
<a href="/pages/admin/projects.html">
<span class="menu-icon">📁</span>
<span class="menu-text">프로젝트 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/workers.html">
<span class="menu-icon">👥</span>
<span class="menu-text">작업자 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/workplaces.html">
<span class="menu-icon">🏗️</span>
<span class="menu-text">작업장 관리</span>
</a>
</li>
<li class="menu-item active">
<a href="/pages/admin/equipments.html">
<span class="menu-icon">⚙️</span>
<span class="menu-text">설비 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/tasks.html">
<span class="menu-icon">📋</span>
<span class="menu-text">작업 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/codes.html">
<span class="menu-icon">🏷️</span>
<span class="menu-text">코드 관리</span>
</a>
</li>
<li class="menu-divider"></li>
<li class="menu-item">
<a href="/pages/dashboard.html">
<span class="menu-icon">🏠</span>
<span class="menu-text">대시보드로</span>
</a>
</li>
</ul>
</nav>
</aside>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">
<span class="title-icon">⚙️</span>
설비 관리
</h1>
<p class="page-description">작업장별 설비 정보를 등록하고 관리합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="openEquipmentModal()">
<span>+ 설비 추가</span>
</button>
</div>
</div>
<!-- 필터 영역 -->
<div class="filter-section">
<div class="filter-group">
<label for="filterWorkplace">작업장</label>
<select id="filterWorkplace" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
</select>
</div>
<div class="filter-group">
<label for="filterType">설비 유형</label>
<select id="filterType" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
</select>
</div>
<div class="filter-group">
<label for="filterStatus">상태</label>
<select id="filterStatus" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
<option value="active">활성</option>
<option value="maintenance">정비중</option>
<option value="inactive">비활성</option>
</select>
</div>
<div class="filter-group">
<label for="searchInput">검색</label>
<input type="text" id="searchInput" class="form-control" placeholder="설비명 또는 코드 검색" oninput="filterEquipments()">
</div>
</div>
<!-- 설비 목록 -->
<div class="content-section">
<div id="equipmentList" class="data-table-container">
<!-- 설비 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</main>
</div>
<!-- 설비 추가/수정 모달 -->
<div id="equipmentModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 700px;">
<div class="modal-header">
<h2 id="modalTitle">설비 추가</h2>
<button class="btn-close" onclick="closeEquipmentModal()">&times;</button>
</div>
<div class="modal-body">
<form id="equipmentForm">
<input type="hidden" id="equipmentId">
<div class="form-row">
<div class="form-group">
<label for="equipmentCode">설비 코드 *</label>
<input type="text" id="equipmentCode" class="form-control" placeholder="예: CNC-01" required>
</div>
<div class="form-group">
<label for="equipmentName">설비명 *</label>
<input type="text" id="equipmentName" class="form-control" placeholder="예: CNC 머시닝 센터" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="equipmentType">설비 유형</label>
<input type="text" id="equipmentType" class="form-control" placeholder="예: CNC, 선반, 밀링 등">
</div>
<div class="form-group">
<label for="workplaceId">작업장</label>
<select id="workplaceId" class="form-control">
<option value="">선택 안함</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="manufacturer">제조사</label>
<input type="text" id="manufacturer" class="form-control" placeholder="예: DMG MORI">
</div>
<div class="form-group">
<label for="modelName">모델명</label>
<input type="text" id="modelName" class="form-control" placeholder="예: NHX-5000">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialNumber">시리얼 번호</label>
<input type="text" id="serialNumber" class="form-control">
</div>
<div class="form-group">
<label for="installationDate">설치일</label>
<input type="date" id="installationDate" class="form-control">
</div>
</div>
<div class="form-group">
<label for="equipmentStatus">상태</label>
<select id="equipmentStatus" class="form-control">
<option value="active">활성</option>
<option value="maintenance">정비중</option>
<option value="inactive">비활성</option>
</select>
</div>
<div class="form-group">
<label for="specifications">사양 정보</label>
<textarea id="specifications" class="form-control" rows="3" placeholder="설비 사양 정보를 입력하세요"></textarea>
</div>
<div class="form-group">
<label for="notes">비고</label>
<textarea id="notes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeEquipmentModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveEquipment()">저장</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/navbar-loader.js?v=5"></script>
<script src="/js/equipment-management.js?v=1"></script>
</body>
</html>

View File

@@ -101,6 +101,23 @@
<!-- 공장 탭들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 공장 레이아웃 지도 관리 섹션 (카테고리가 선택된 경우에만 표시) -->
<div class="code-section" id="layoutMapSection" style="display: none;">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">🗺️</span>
<span id="selectedCategoryName"></span> 레이아웃 지도
</h2>
<button class="btn btn-secondary" onclick="openLayoutMapModal()">
<span class="btn-icon">⚙️</span>
지도 설정
</button>
</div>
<div id="layoutMapPreview" style="padding: 20px; background: #f9fafb; border-radius: 8px; text-align: center;">
<!-- 레이아웃 이미지 미리보기가 여기에 표시됩니다 -->
</div>
</div>
<!-- 작업장 목록 -->
<div class="code-section">
<div class="section-header">
@@ -195,6 +212,26 @@
<input type="text" id="workplaceName" class="form-control" placeholder="예: 서스작업장, 조립구역" required>
</div>
<div class="form-group">
<label class="form-label">작업장 용도</label>
<select id="workplacePurpose" class="form-control">
<option value="">선택 안 함</option>
<option value="작업구역">작업구역</option>
<option value="설비">설비</option>
<option value="휴게시설">휴게시설</option>
<option value="회의실">회의실</option>
<option value="창고">창고</option>
<option value="기타">기타</option>
</select>
<small class="form-help">작업장의 주요 용도를 선택하세요</small>
</div>
<div class="form-group">
<label class="form-label">표시 순서</label>
<input type="number" id="displayPriority" class="form-control" value="0" min="0">
<small class="form-help">숫자가 작을수록 먼저 표시됩니다</small>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="workplaceDescription" class="form-control" rows="4" placeholder="작업장에 대한 설명을 입력하세요"></textarea>
@@ -213,9 +250,159 @@
</div>
</div>
</div>
<!-- 작업장 지도 관리 모달 -->
<div id="workplaceMapModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
<div class="modal-header">
<h2 id="workplaceMapModalTitle">작업장 지도 관리</h2>
<button class="modal-close-btn" onclick="closeWorkplaceMapModal()">×</button>
</div>
<div class="modal-body" style="overflow-y: auto;">
<!-- Step 1: 이미지 업로드 -->
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 작업장 레이아웃 이미지 업로드</h3>
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
작업장의 상세 레이아웃 이미지를 업로드하세요
</p>
<div class="form-group">
<label class="form-label">현재 이미지</label>
<div id="workplaceLayoutPreview" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center; min-height: 200px;">
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
</div>
</div>
<div class="form-group">
<label class="form-label">새 이미지 업로드</label>
<input type="file" id="workplaceLayoutFile" accept="image/*" class="form-control" onchange="previewWorkplaceLayoutImage(event)">
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
</div>
<button type="button" class="btn btn-primary" onclick="uploadWorkplaceLayout()">
📤 이미지 업로드
</button>
</div>
<!-- Step 2: 설비/영역 정의 -->
<div class="form-section">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 설비 위치 정의 (선택사항)</h3>
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
작업장 이미지 위에 마우스로 드래그하여 각 설비의 위치를 지정하세요
</p>
<!-- 영역 그리기 캔버스 -->
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="workplaceCanvasContainer">
<canvas id="workplaceRegionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
</div>
<!-- 설비 선택 및 영역 목록 -->
<div class="form-group">
<label class="form-label">설비 이름 입력</label>
<input type="text" id="equipmentNameInput" class="form-control" placeholder="예: CNC-01, 선반기-A" style="margin-bottom: 12px;">
<small class="form-help">드래그로 영역을 선택한 후 설비 이름을 입력하고 저장하세요</small>
<div style="display: flex; gap: 8px; margin-top: 12px;">
<button type="button" class="btn btn-secondary" onclick="clearWorkplaceCurrentRegion()">
🗑️ 현재 영역 지우기
</button>
<button type="button" class="btn btn-primary" onclick="saveWorkplaceEquipmentRegion()">
💾 설비 위치 저장
</button>
</div>
</div>
<!-- 정의된 영역 목록 -->
<div class="form-group">
<label class="form-label">정의된 설비 목록</label>
<div id="workplaceEquipmentList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
<p style="color: #94a3b8; text-align: center;">아직 정의된 설비가 없습니다</p>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceMapModal()">닫기</button>
</div>
</div>
</div>
<!-- 레이아웃 지도 설정 모달 -->
<div id="layoutMapModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
<div class="modal-header">
<h2>🗺️ 공장 레이아웃 지도 설정</h2>
<button class="modal-close-btn" onclick="closeLayoutMapModal()">×</button>
</div>
<div class="modal-body" style="overflow-y: auto;">
<!-- Step 1: 이미지 업로드 -->
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 공장 레이아웃 이미지 업로드</h3>
<div class="form-group">
<label class="form-label">현재 이미지</label>
<div id="currentLayoutImage" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center;">
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
</div>
</div>
<div class="form-group">
<label class="form-label">새 이미지 업로드</label>
<input type="file" id="layoutImageFile" accept="image/*" class="form-control" onchange="previewLayoutImage(event)">
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
</div>
<button type="button" class="btn btn-primary" onclick="uploadLayoutImage()">
📤 이미지 업로드
</button>
</div>
<!-- Step 2: 작업장 영역 정의 -->
<div class="form-section">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 작업장 영역 정의</h3>
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
이미지 위에 마우스로 드래그하여 각 작업장의 위치를 지정하세요
</p>
<!-- 영역 그리기 캔버스 -->
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="canvasContainer">
<canvas id="regionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
</div>
<!-- 작업장 선택 및 영역 목록 -->
<div class="form-group">
<label class="form-label">작업장 선택</label>
<select id="regionWorkplaceSelect" class="form-control" style="margin-bottom: 12px;">
<option value="">작업장을 선택하세요</option>
</select>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-secondary" onclick="clearCurrentRegion()">
🗑️ 현재 영역 지우기
</button>
<button type="button" class="btn btn-primary" onclick="saveRegion()">
💾 선택 영역 저장
</button>
</div>
</div>
<!-- 정의된 영역 목록 -->
<div class="form-group">
<label class="form-label">정의된 영역 목록</label>
<div id="regionList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
<!-- 영역 목록이 여기에 표시됩니다 -->
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeLayoutMapModal()">닫기</button>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/workplace-management.js?v=1"></script>
<script type="module" src="/js/workplace-management.js?v=3"></script>
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
</body>
</html>