## 백엔드 보안 수정 - 하드코딩된 비밀번호 및 JWT 시크릿 폴백 제거 - SQL Injection 방지를 위한 화이트리스트 검증 추가 - 인증 미적용 API 라우트에 requireAuth 미들웨어 적용 - CSRF 보호 미들웨어 구현 (csrf.js) - 파일 업로드 보안 유틸리티 추가 (fileUploadSecurity.js) - 비밀번호 정책 검증 유틸리티 추가 (passwordValidator.js) ## 프론트엔드 XSS 방지 - api-base.js에 전역 escapeHtml() 함수 추가 - 17개 주요 JS 파일에 escapeHtml 적용: - tbm.js, daily-patrol.js, daily-work-report.js - task-management.js, workplace-status.js - equipment-detail.js, equipment-management.js - issue-detail.js, issue-report.js - vacation-common.js, worker-management.js - safety-report-list.js, nonconformity-list.js - project-management.js, workplace-management.js ## 정리 - 백업 폴더 및 빈 파일 삭제 - SECURITY_GUIDE.md 문서 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2255 lines
75 KiB
JavaScript
2255 lines
75 KiB
JavaScript
// 작업장 관리 페이지 JavaScript
|
||
//
|
||
// 참고: 이 파일은 점진적 마이그레이션 중입니다.
|
||
// 새로운 모듈 시스템: /js/workplace-management/
|
||
// - state.js: 전역 상태 관리
|
||
// - utils.js: 유틸리티 함수
|
||
// - api.js: API 클라이언트
|
||
// - index.js: 메인 컨트롤러
|
||
|
||
// 전역 변수 (모듈 시스템이 없을 때만 사용)
|
||
let categories = window.WorkplaceState?.categories || [];
|
||
let workplaces = window.WorkplaceState?.workplaces || [];
|
||
let currentCategoryId = window.WorkplaceState?.currentCategoryId || '';
|
||
let currentEditingCategory = null;
|
||
let currentEditingWorkplace = null;
|
||
|
||
// 페이지 초기화 (모듈 시스템이 없을 때만)
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// 모듈 시스템이 이미 로드되어 있으면 초기화 건너뜀
|
||
if (window.WorkplaceController) {
|
||
console.log('[workplace-management.js] 모듈 시스템 감지 - 기존 초기화 건너뜀');
|
||
return;
|
||
}
|
||
|
||
console.log('🏗️ 작업장 관리 페이지 초기화 시작 (레거시)');
|
||
loadAllData();
|
||
});
|
||
|
||
// 모든 데이터 로드
|
||
async function loadAllData() {
|
||
// 모듈 시스템이 있으면 위임
|
||
if (window.WorkplaceController) {
|
||
return window.WorkplaceController.loadAllData();
|
||
}
|
||
|
||
try {
|
||
await Promise.all([
|
||
loadCategories(),
|
||
loadWorkplaces()
|
||
]);
|
||
|
||
renderCategoryTabs();
|
||
renderWorkplaces();
|
||
updateStatistics();
|
||
} catch (error) {
|
||
console.error('데이터 로딩 오류:', error);
|
||
showToast('데이터를 불러오는데 실패했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 카테고리(공장) 관련 ====================
|
||
|
||
// 카테고리 목록 로드
|
||
async function loadCategories() {
|
||
// 모듈 시스템이 있으면 위임
|
||
if (window.WorkplaceAPI) {
|
||
const result = await window.WorkplaceAPI.loadCategories();
|
||
categories = window.WorkplaceState?.categories || result;
|
||
return result;
|
||
}
|
||
|
||
try {
|
||
const response = await window.apiCall('/workplaces/categories', 'GET');
|
||
|
||
let categoryData = [];
|
||
if (response && response.success && Array.isArray(response.data)) {
|
||
categoryData = response.data;
|
||
} else if (Array.isArray(response)) {
|
||
categoryData = response;
|
||
}
|
||
|
||
categories = categoryData;
|
||
console.log(`✅ 카테고리 ${categories.length}개 로드 완료`);
|
||
} catch (error) {
|
||
console.error('카테고리 로딩 오류:', error);
|
||
categories = [];
|
||
}
|
||
}
|
||
|
||
// 카테고리 탭 렌더링
|
||
function renderCategoryTabs() {
|
||
const tabsContainer = document.getElementById('categoryTabs');
|
||
if (!tabsContainer) return;
|
||
|
||
// 전체 탭은 항상 표시
|
||
let tabsHtml = `
|
||
<button class="wp-tab-btn ${currentCategoryId === '' ? 'active' : ''}"
|
||
data-category=""
|
||
onclick="switchCategory('')">
|
||
<span class="wp-tab-icon">🏗️</span>
|
||
전체
|
||
<span class="wp-tab-count">${workplaces.length}</span>
|
||
</button>
|
||
`;
|
||
|
||
// 각 카테고리 탭 추가
|
||
categories.forEach(category => {
|
||
const count = workplaces.filter(w => w.category_id === category.category_id).length;
|
||
const isActive = currentCategoryId === category.category_id;
|
||
|
||
tabsHtml += `
|
||
<button class="wp-tab-btn ${isActive ? 'active' : ''}"
|
||
data-category="${category.category_id}"
|
||
onclick="switchCategory(${category.category_id})">
|
||
<span class="wp-tab-icon">🏭</span>
|
||
${category.category_name}
|
||
<span class="wp-tab-count">${count}</span>
|
||
</button>
|
||
`;
|
||
});
|
||
|
||
tabsContainer.innerHTML = tabsHtml;
|
||
}
|
||
|
||
// 카테고리 전환
|
||
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/', '/');
|
||
|
||
// 설비 정보 로드
|
||
let equipmentCount = 0;
|
||
try {
|
||
const eqResponse = await window.apiCall(`/equipments/workplace/${workplace.workplace_id}`, 'GET');
|
||
if (eqResponse && eqResponse.success && Array.isArray(eqResponse.data)) {
|
||
equipmentCount = eqResponse.data.filter(eq => eq.map_x_percent != null).length;
|
||
}
|
||
} catch (e) {
|
||
console.debug('설비 정보 로드 실패');
|
||
}
|
||
|
||
const canvasId = `layout-canvas-${workplace.workplace_id}`;
|
||
thumbnailDiv.innerHTML = `
|
||
<div style="text-align: center; padding: 10px; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 8px; border: 1px solid #bae6fd; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<span style="font-size: 12px; color: #0369a1; font-weight: 600;">📍 작업장 지도</span>
|
||
${equipmentCount > 0 ? `<span style="font-size: 11px; background: #10b981; color: white; padding: 2px 8px; border-radius: 10px;">설비 ${equipmentCount}개</span>` : ''}
|
||
</div>
|
||
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.12);"></canvas>
|
||
<div style="font-size: 11px; color: #64748b; margin-top: 8px;">클릭하여 지도 관리</div>
|
||
</div>
|
||
`;
|
||
|
||
// 캔버스에 이미지 및 설비 영역 그리기
|
||
await loadWorkplaceCanvasWithEquipments(workplace.workplace_id, fullImageUrl, canvasId);
|
||
return;
|
||
}
|
||
|
||
// 작업장에 이미지가 없으면 카테고리 지도의 영역 표시
|
||
try {
|
||
// 해당 작업장의 지도 영역 정보 가져오기
|
||
const response = await window.apiCall(`/workplaces/map-regions/workplace/${workplace.workplace_id}`, 'GET');
|
||
|
||
if (!response || (!response.success && !response.region_id)) {
|
||
// 영역이 정의되지 않은 경우 지도 업로드 안내 표시
|
||
thumbnailDiv.innerHTML = `
|
||
<div style="text-align: center; padding: 16px; background: #f9fafb; border-radius: 8px; border: 2px dashed #cbd5e1; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
|
||
<div style="font-size: 24px; margin-bottom: 8px;">🗺️</div>
|
||
<div style="font-size: 12px; color: #64748b;">클릭하여 지도 설정</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const region = response.success ? response.data : response;
|
||
|
||
// 영역 좌표 데이터가 없으면 종료
|
||
if (!region || region.x_start === undefined || region.y_start === undefined ||
|
||
region.x_end === undefined || region.y_end === undefined) {
|
||
return;
|
||
}
|
||
|
||
// 카테고리 정보에서 레이아웃 이미지 가져오기
|
||
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: 10px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
|
||
<div style="font-size: 12px; color: #64748b; margin-bottom: 6px; font-weight: 500;">📍 공장 지도 내 위치</div>
|
||
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);"></canvas>
|
||
<div style="font-size: 11px; color: #94a3b8; margin-top: 6px;">클릭하여 상세 지도 설정</div>
|
||
</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;
|
||
|
||
// 썸네일 크기 설정 (최대 너비 350px)
|
||
const maxThumbWidth = 350;
|
||
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}의 지도 영역 없음`);
|
||
}
|
||
}
|
||
|
||
// 작업장 캔버스에 설비 영역 함께 그리기
|
||
async function loadWorkplaceCanvasWithEquipments(workplaceId, imageUrl, canvasId) {
|
||
const img = new Image();
|
||
img.onload = async function() {
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) return;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// 썸네일 크기 설정 (최대 너비 400px)
|
||
const maxThumbWidth = 400;
|
||
const scale = img.width > maxThumbWidth ? maxThumbWidth / 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(`/equipments/workplace/${workplaceId}`, 'GET');
|
||
let equipments = [];
|
||
if (response && response.success && Array.isArray(response.data)) {
|
||
equipments = response.data.filter(eq => eq.map_x_percent != null);
|
||
}
|
||
|
||
// 설비 영역 그리기
|
||
equipments.forEach(eq => {
|
||
const x = (parseFloat(eq.map_x_percent) / 100) * canvas.width;
|
||
const y = (parseFloat(eq.map_y_percent) / 100) * canvas.height;
|
||
const width = (parseFloat(eq.map_width_percent || 10) / 100) * canvas.width;
|
||
const height = (parseFloat(eq.map_height_percent || 10) / 100) * canvas.height;
|
||
|
||
// 영역 배경
|
||
ctx.fillStyle = 'rgba(16, 185, 129, 0.2)';
|
||
ctx.fillRect(x, y, width, height);
|
||
|
||
// 영역 테두리
|
||
ctx.strokeStyle = '#10b981';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(x, y, width, height);
|
||
|
||
// 설비 코드 표시
|
||
if (eq.equipment_code) {
|
||
ctx.font = 'bold 10px sans-serif';
|
||
const textMetrics = ctx.measureText(eq.equipment_code);
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
||
ctx.fillRect(x + 2, y + 2, textMetrics.width + 6, 14);
|
||
ctx.fillStyle = '#047857';
|
||
ctx.fillText(eq.equipment_code, x + 5, y + 12);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.debug('설비 영역 로드 실패');
|
||
}
|
||
};
|
||
img.src = imageUrl;
|
||
}
|
||
|
||
// 카테고리 모달 열기
|
||
function openCategoryModal(categoryData = null) {
|
||
const modal = document.getElementById('categoryModal');
|
||
const modalTitle = document.getElementById('categoryModalTitle');
|
||
const deleteBtn = document.getElementById('deleteCategoryBtn');
|
||
|
||
if (!modal) return;
|
||
|
||
currentEditingCategory = categoryData;
|
||
|
||
if (categoryData) {
|
||
// 수정 모드
|
||
modalTitle.textContent = '공장 수정';
|
||
deleteBtn.style.display = 'inline-flex';
|
||
|
||
document.getElementById('categoryId').value = categoryData.category_id;
|
||
document.getElementById('categoryName').value = categoryData.category_name || '';
|
||
document.getElementById('categoryDescription').value = categoryData.description || '';
|
||
document.getElementById('categoryOrder').value = categoryData.display_order || 0;
|
||
} else {
|
||
// 신규 등록 모드
|
||
modalTitle.textContent = '공장 추가';
|
||
deleteBtn.style.display = 'none';
|
||
|
||
document.getElementById('categoryForm').reset();
|
||
document.getElementById('categoryId').value = '';
|
||
}
|
||
|
||
modal.style.display = 'flex';
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
setTimeout(() => {
|
||
document.getElementById('categoryName').focus();
|
||
}, 100);
|
||
}
|
||
|
||
// 카테고리 모달 닫기
|
||
function closeCategoryModal() {
|
||
const modal = document.getElementById('categoryModal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
document.body.style.overflow = '';
|
||
currentEditingCategory = null;
|
||
}
|
||
}
|
||
|
||
// 카테고리 저장
|
||
async function saveCategory() {
|
||
try {
|
||
const categoryId = document.getElementById('categoryId').value;
|
||
|
||
const categoryData = {
|
||
category_name: document.getElementById('categoryName').value.trim(),
|
||
description: document.getElementById('categoryDescription').value.trim() || null,
|
||
display_order: parseInt(document.getElementById('categoryOrder').value) || 0,
|
||
is_active: true
|
||
};
|
||
|
||
if (!categoryData.category_name) {
|
||
showToast('공장명은 필수 입력 항목입니다.', 'error');
|
||
return;
|
||
}
|
||
|
||
console.log('💾 저장할 카테고리 데이터:', categoryData);
|
||
|
||
let response;
|
||
if (categoryId) {
|
||
// 수정
|
||
response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
|
||
} else {
|
||
// 신규 등록
|
||
response = await window.apiCall('/workplaces/categories', 'POST', categoryData);
|
||
}
|
||
|
||
if (response && (response.success || response.category_id)) {
|
||
const action = categoryId ? '수정' : '등록';
|
||
showToast(`공장이 성공적으로 ${action}되었습니다.`, 'success');
|
||
|
||
closeCategoryModal();
|
||
await loadAllData();
|
||
} else {
|
||
throw new Error(response?.message || '저장에 실패했습니다.');
|
||
}
|
||
} catch (error) {
|
||
console.error('카테고리 저장 오류:', error);
|
||
showToast(error.message || '카테고리 저장 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// 카테고리 삭제
|
||
async function deleteCategory() {
|
||
if (!currentEditingCategory) return;
|
||
|
||
if (!confirm(`"${currentEditingCategory.category_name}" 공장을 정말 삭제하시겠습니까?\n\n⚠️ 이 공장에 속한 모든 작업장의 공장 정보가 제거됩니다.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await window.apiCall(`/workplaces/categories/${currentEditingCategory.category_id}`, 'DELETE');
|
||
|
||
if (response && response.success) {
|
||
showToast('공장이 성공적으로 삭제되었습니다.', 'success');
|
||
|
||
closeCategoryModal();
|
||
await loadAllData();
|
||
} else {
|
||
throw new Error(response?.message || '삭제에 실패했습니다.');
|
||
}
|
||
} catch (error) {
|
||
console.error('카테고리 삭제 오류:', error);
|
||
showToast(error.message || '카테고리 삭제 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 작업장 관련 ====================
|
||
|
||
// 작업장 목록 로드
|
||
async function loadWorkplaces() {
|
||
try {
|
||
const response = await window.apiCall('/workplaces', 'GET');
|
||
|
||
let workplaceData = [];
|
||
if (response && response.success && Array.isArray(response.data)) {
|
||
workplaceData = response.data;
|
||
} else if (Array.isArray(response)) {
|
||
workplaceData = response;
|
||
}
|
||
|
||
workplaces = workplaceData;
|
||
console.log(`✅ 작업장 ${workplaces.length}개 로드 완료`);
|
||
} catch (error) {
|
||
console.error('작업장 로딩 오류:', error);
|
||
workplaces = [];
|
||
}
|
||
}
|
||
|
||
// 작업장 렌더링
|
||
function renderWorkplaces() {
|
||
const grid = document.getElementById('workplaceGrid');
|
||
if (!grid) return;
|
||
|
||
// 현재 카테고리별 필터링
|
||
const filtered = currentCategoryId === ''
|
||
? workplaces
|
||
: workplaces.filter(w => w.category_id == currentCategoryId);
|
||
|
||
if (filtered.length === 0) {
|
||
grid.innerHTML = `
|
||
<div class="wp-empty-state">
|
||
<div class="wp-empty-icon">🏗️</div>
|
||
<h3 class="wp-empty-title">등록된 작업장이 없습니다</h3>
|
||
<p class="wp-empty-description">"작업장 추가" 버튼을 눌러 작업장을 등록해보세요</p>
|
||
<button class="wp-btn wp-btn-primary" onclick="openWorkplaceModal()">
|
||
<span class="wp-btn-icon">➕</span>
|
||
첫 작업장 추가하기
|
||
</button>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
let gridHtml = '';
|
||
|
||
filtered.forEach(workplace => {
|
||
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="wp-card ${isActive ? '' : 'inactive'}" onclick="editWorkplace(${workplace.workplace_id})">
|
||
<div class="wp-card-header">
|
||
<div class="wp-card-icon">${purposeIcon}</div>
|
||
<div class="wp-card-info">
|
||
<h3 class="wp-card-title">${workplace.workplace_name}</h3>
|
||
<div class="wp-card-tags">
|
||
${workplace.category_id ? `<span class="wp-card-tag factory">🏭 ${categoryName}</span>` : ''}
|
||
${workplace.workplace_purpose ? `<span class="wp-card-tag purpose">${workplace.workplace_purpose}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="wp-card-actions">
|
||
<button class="wp-card-btn map" onclick="event.stopPropagation(); openWorkplaceMapModal(${workplace.workplace_id})" title="지도 관리">
|
||
🗺️
|
||
</button>
|
||
<button class="wp-card-btn edit" onclick="event.stopPropagation(); editWorkplace(${workplace.workplace_id})" title="수정">
|
||
✏️
|
||
</button>
|
||
<button class="wp-card-btn delete" onclick="event.stopPropagation(); confirmDeleteWorkplace(${workplace.workplace_id})" title="삭제">
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
${workplace.description ? `<p class="wp-card-description">${workplace.description}</p>` : ''}
|
||
<div class="wp-card-map" id="workplace-map-${workplace.workplace_id}"></div>
|
||
<div class="wp-card-meta">
|
||
<span class="wp-card-date">등록: ${formatDate(workplace.created_at)}</span>
|
||
${workplace.updated_at !== workplace.created_at ? `<span class="wp-card-date">수정: ${formatDate(workplace.updated_at)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
grid.innerHTML = gridHtml;
|
||
|
||
// 각 작업장의 지도 미리보기 로드
|
||
filtered.forEach(workplace => {
|
||
if (workplace.category_id) {
|
||
loadWorkplaceMapThumbnail(workplace);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 작업장 모달 열기
|
||
function openWorkplaceModal(workplaceData = null) {
|
||
const modal = document.getElementById('workplaceModal');
|
||
const modalTitle = document.getElementById('workplaceModalTitle');
|
||
const deleteBtn = document.getElementById('deleteWorkplaceBtn');
|
||
const categorySelect = document.getElementById('workplaceCategoryId');
|
||
|
||
if (!modal) return;
|
||
|
||
currentEditingWorkplace = workplaceData;
|
||
|
||
// 카테고리 선택 옵션 업데이트
|
||
let categoryOptions = '<option value="">미분류</option>';
|
||
categories.forEach(cat => {
|
||
categoryOptions += `<option value="${cat.category_id}">${cat.category_name}</option>`;
|
||
});
|
||
categorySelect.innerHTML = categoryOptions;
|
||
|
||
if (workplaceData) {
|
||
// 수정 모드
|
||
modalTitle.textContent = '작업장 수정';
|
||
deleteBtn.style.display = 'inline-flex';
|
||
|
||
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 {
|
||
// 신규 등록 모드
|
||
modalTitle.textContent = '작업장 추가';
|
||
deleteBtn.style.display = 'none';
|
||
|
||
document.getElementById('workplaceForm').reset();
|
||
document.getElementById('workplaceId').value = '';
|
||
|
||
// 현재 선택된 카테고리가 있으면 자동 선택
|
||
if (currentCategoryId) {
|
||
document.getElementById('workplaceCategoryId').value = currentCategoryId;
|
||
}
|
||
}
|
||
|
||
modal.style.display = 'flex';
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
setTimeout(() => {
|
||
document.getElementById('workplaceName').focus();
|
||
}, 100);
|
||
}
|
||
|
||
// 작업장 모달 닫기
|
||
function closeWorkplaceModal() {
|
||
const modal = document.getElementById('workplaceModal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
document.body.style.overflow = '';
|
||
currentEditingWorkplace = null;
|
||
}
|
||
}
|
||
|
||
// 작업장 편집
|
||
function editWorkplace(workplaceId) {
|
||
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||
if (workplace) {
|
||
openWorkplaceModal(workplace);
|
||
} else {
|
||
showToast('작업장을 찾을 수 없습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// 작업장 저장
|
||
async function saveWorkplace() {
|
||
try {
|
||
const workplaceId = document.getElementById('workplaceId').value;
|
||
|
||
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
|
||
};
|
||
|
||
if (!workplaceData.workplace_name) {
|
||
showToast('작업장명은 필수 입력 항목입니다.', 'error');
|
||
return;
|
||
}
|
||
|
||
console.log('💾 저장할 작업장 데이터:', workplaceData);
|
||
|
||
let response;
|
||
if (workplaceId) {
|
||
// 수정
|
||
response = await window.apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
|
||
} else {
|
||
// 신규 등록
|
||
response = await window.apiCall('/workplaces', 'POST', workplaceData);
|
||
}
|
||
|
||
if (response && (response.success || response.workplace_id)) {
|
||
const action = workplaceId ? '수정' : '등록';
|
||
showToast(`작업장이 성공적으로 ${action}되었습니다.`, 'success');
|
||
|
||
closeWorkplaceModal();
|
||
await loadAllData();
|
||
} else {
|
||
throw new Error(response?.message || '저장에 실패했습니다.');
|
||
}
|
||
} catch (error) {
|
||
console.error('작업장 저장 오류:', error);
|
||
showToast(error.message || '작업장 저장 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// 작업장 삭제 확인
|
||
function confirmDeleteWorkplace(workplaceId) {
|
||
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||
|
||
if (!workplace) {
|
||
showToast('작업장을 찾을 수 없습니다.', 'error');
|
||
return;
|
||
}
|
||
|
||
if (confirm(`"${workplace.workplace_name}" 작업장을 정말 삭제하시겠습니까?\n\n⚠️ 삭제된 작업장은 복구할 수 없습니다.`)) {
|
||
deleteWorkplaceById(workplaceId);
|
||
}
|
||
}
|
||
|
||
// 작업장 삭제 (수정 모드에서)
|
||
function deleteWorkplace() {
|
||
if (currentEditingWorkplace) {
|
||
confirmDeleteWorkplace(currentEditingWorkplace.workplace_id);
|
||
}
|
||
}
|
||
|
||
// 작업장 삭제 실행
|
||
async function deleteWorkplaceById(workplaceId) {
|
||
try {
|
||
const response = await window.apiCall(`/workplaces/${workplaceId}`, 'DELETE');
|
||
|
||
if (response && response.success) {
|
||
showToast('작업장이 성공적으로 삭제되었습니다.', 'success');
|
||
|
||
closeWorkplaceModal();
|
||
await loadAllData();
|
||
} else {
|
||
throw new Error(response?.message || '삭제에 실패했습니다.');
|
||
}
|
||
} catch (error) {
|
||
console.error('작업장 삭제 오류:', error);
|
||
showToast(error.message || '작업장 삭제 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 유틸리티 ====================
|
||
|
||
// 전체 새로고침
|
||
async function refreshWorkplaces() {
|
||
const refreshBtn = document.querySelector('.btn-secondary');
|
||
if (refreshBtn) {
|
||
const originalText = refreshBtn.innerHTML;
|
||
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
|
||
refreshBtn.disabled = true;
|
||
|
||
await loadAllData();
|
||
|
||
refreshBtn.innerHTML = originalText;
|
||
refreshBtn.disabled = false;
|
||
} else {
|
||
await loadAllData();
|
||
}
|
||
|
||
showToast('데이터가 새로고침되었습니다.', 'success');
|
||
}
|
||
|
||
// 통계 업데이트
|
||
async function updateStatistics() {
|
||
const total = workplaces.length;
|
||
const active = workplaces.filter(w => w.is_active === 1 || w.is_active === true).length;
|
||
const factoryTotal = categories.length;
|
||
|
||
// 상단 통계 카드 업데이트
|
||
const factoryCountEl = document.getElementById('factoryCount');
|
||
const totalCountEl = document.getElementById('totalCount');
|
||
const activeCountEl = document.getElementById('activeCount');
|
||
const equipmentCountEl = document.getElementById('equipmentCount');
|
||
|
||
if (factoryCountEl) factoryCountEl.textContent = factoryTotal;
|
||
if (totalCountEl) totalCountEl.textContent = total;
|
||
if (activeCountEl) activeCountEl.textContent = active;
|
||
|
||
// 설비 수 조회 및 업데이트
|
||
if (equipmentCountEl) {
|
||
try {
|
||
const response = await window.apiCall('/equipments', 'GET');
|
||
let equipmentCount = 0;
|
||
if (response && response.success && Array.isArray(response.data)) {
|
||
equipmentCount = response.data.length;
|
||
} else if (Array.isArray(response)) {
|
||
equipmentCount = response.length;
|
||
}
|
||
equipmentCountEl.textContent = equipmentCount;
|
||
} catch (e) {
|
||
equipmentCountEl.textContent = '-';
|
||
}
|
||
}
|
||
|
||
// 섹션 통계 업데이트
|
||
const sectionTotalEl = document.getElementById('sectionTotalCount');
|
||
const sectionActiveEl = document.getElementById('sectionActiveCount');
|
||
|
||
// 현재 필터링된 작업장 기준
|
||
const filtered = currentCategoryId === ''
|
||
? workplaces
|
||
: workplaces.filter(w => w.category_id == currentCategoryId);
|
||
const filteredActive = filtered.filter(w => w.is_active === 1 || w.is_active === true).length;
|
||
|
||
if (sectionTotalEl) sectionTotalEl.textContent = filtered.length;
|
||
if (sectionActiveEl) sectionActiveEl.textContent = filteredActive;
|
||
}
|
||
|
||
// 날짜 포맷팅
|
||
function formatDate(dateString) {
|
||
if (!dateString) return '';
|
||
|
||
const date = new Date(dateString);
|
||
return date.toLocaleDateString('ko-KR', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit'
|
||
});
|
||
}
|
||
|
||
// 토스트 메시지 표시
|
||
function showToast(message, type = 'info') {
|
||
// 기존 토스트 제거
|
||
const existingToast = document.querySelector('.toast');
|
||
if (existingToast) {
|
||
existingToast.remove();
|
||
}
|
||
|
||
// 새 토스트 생성
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
toast.textContent = message;
|
||
|
||
// 스타일 적용
|
||
Object.assign(toast.style, {
|
||
position: 'fixed',
|
||
top: '20px',
|
||
right: '20px',
|
||
padding: '12px 24px',
|
||
borderRadius: '8px',
|
||
color: 'white',
|
||
fontWeight: '500',
|
||
zIndex: '1000',
|
||
transform: 'translateX(100%)',
|
||
transition: 'transform 0.3s ease'
|
||
});
|
||
|
||
// 타입별 배경색
|
||
const colors = {
|
||
success: '#10b981',
|
||
error: '#ef4444',
|
||
warning: '#f59e0b',
|
||
info: '#3b82f6'
|
||
};
|
||
toast.style.backgroundColor = colors[type] || colors.info;
|
||
|
||
document.body.appendChild(toast);
|
||
|
||
// 애니메이션
|
||
setTimeout(() => {
|
||
toast.style.transform = 'translateX(0)';
|
||
}, 100);
|
||
|
||
// 자동 제거
|
||
setTimeout(() => {
|
||
toast.style.transform = 'translateX(100%)';
|
||
setTimeout(() => {
|
||
if (toast.parentNode) {
|
||
toast.remove();
|
||
}
|
||
}, 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// 전역 함수 및 변수로 노출 (다른 모듈에서 접근 가능하도록)
|
||
// 모듈 시스템이 이미 정의했으면 건너뜀
|
||
if (!window.WorkplaceState) {
|
||
// 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 = [];
|
||
let existingEquipments = []; // DB에서 로드한 기존 설비 목록
|
||
let allEquipments = []; // 시스템 전체 설비 목록 (드롭다운 선택용)
|
||
|
||
// 작업장 지도 모달 열기
|
||
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>';
|
||
}
|
||
|
||
// 설비 영역 목록 로드 (API 연동)
|
||
await Promise.all([
|
||
loadWorkplaceEquipments(workplaceId),
|
||
loadAllEquipments()
|
||
]);
|
||
renderWorkplaceEquipmentList();
|
||
|
||
// 모달 표시
|
||
const modal = document.getElementById('workplaceMapModal');
|
||
if (modal) {
|
||
modal.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
// 작업장의 설비 목록 로드
|
||
async function loadWorkplaceEquipments(workplaceId) {
|
||
try {
|
||
const response = await window.apiCall(`/equipments/workplace/${workplaceId}`, 'GET');
|
||
|
||
let equipments = [];
|
||
if (response && response.success && Array.isArray(response.data)) {
|
||
equipments = response.data;
|
||
} else if (Array.isArray(response)) {
|
||
equipments = response;
|
||
}
|
||
|
||
// 지도 영역이 있는 설비만 workplaceEquipmentRegions에 추가
|
||
workplaceEquipmentRegions = equipments
|
||
.filter(eq => eq.map_x_percent != null && eq.map_y_percent != null)
|
||
.map(eq => ({
|
||
equipment_id: eq.equipment_id,
|
||
equipment_name: eq.equipment_name,
|
||
equipment_code: eq.equipment_code,
|
||
x_percent: parseFloat(eq.map_x_percent),
|
||
y_percent: parseFloat(eq.map_y_percent),
|
||
width_percent: parseFloat(eq.map_width_percent) || 10,
|
||
height_percent: parseFloat(eq.map_height_percent) || 10
|
||
}));
|
||
|
||
// 해당 작업장에 할당된 설비 목록 저장
|
||
existingEquipments = equipments;
|
||
|
||
console.log(`✅ 작업장 ${workplaceId}의 설비 ${equipments.length}개 로드 완료 (지도 영역: ${workplaceEquipmentRegions.length}개)`);
|
||
} catch (error) {
|
||
console.error('설비 로드 오류:', error);
|
||
workplaceEquipmentRegions = [];
|
||
existingEquipments = [];
|
||
}
|
||
}
|
||
|
||
// 시스템 전체 설비 목록 로드 (드롭다운 선택용)
|
||
async function loadAllEquipments() {
|
||
try {
|
||
const response = await window.apiCall('/equipments', 'GET');
|
||
|
||
let equipments = [];
|
||
if (response && response.success && Array.isArray(response.data)) {
|
||
equipments = response.data;
|
||
} else if (Array.isArray(response)) {
|
||
equipments = response;
|
||
}
|
||
|
||
allEquipments = equipments;
|
||
console.log(`✅ 전체 설비 ${allEquipments.length}개 로드 완료`);
|
||
} catch (error) {
|
||
console.error('전체 설비 로드 오류:', error);
|
||
allEquipments = [];
|
||
}
|
||
}
|
||
|
||
// 작업장 지도 모달 닫기
|
||
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 {
|
||
showToast('이미지 업로드 중...', 'info');
|
||
|
||
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;">`;
|
||
|
||
// 캔버스 초기화 (설비 영역 편집용)
|
||
initWorkplaceCanvas(fullImageUrl);
|
||
}
|
||
|
||
// 파일 입력 초기화
|
||
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');
|
||
|
||
// 캔버스 크기 설정 (최대 너비를 모달 크기에 맞게)
|
||
const container = document.getElementById('workplaceCanvasContainer');
|
||
const maxWidth = container ? Math.min(container.clientWidth - 20, 900) : 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;
|
||
workplaceCanvas.onmouseleave = endWorkplaceDraw;
|
||
|
||
// 터치 이벤트 지원
|
||
workplaceCanvas.ontouchstart = handleWorkplaceTouchStart;
|
||
workplaceCanvas.ontouchmove = handleWorkplaceTouchMove;
|
||
workplaceCanvas.ontouchend = endWorkplaceDraw;
|
||
};
|
||
img.onerror = function() {
|
||
console.error('작업장 이미지 로드 실패:', imageUrl);
|
||
};
|
||
img.src = imageUrl;
|
||
}
|
||
|
||
// 터치 이벤트 핸들러
|
||
function handleWorkplaceTouchStart(e) {
|
||
e.preventDefault();
|
||
const touch = e.touches[0];
|
||
const rect = workplaceCanvas.getBoundingClientRect();
|
||
workplaceIsDrawing = true;
|
||
workplaceStartX = touch.clientX - rect.left;
|
||
workplaceStartY = touch.clientY - rect.top;
|
||
}
|
||
|
||
function handleWorkplaceTouchMove(e) {
|
||
e.preventDefault();
|
||
if (!workplaceIsDrawing) return;
|
||
|
||
const touch = e.touches[0];
|
||
const rect = workplaceCanvas.getBoundingClientRect();
|
||
const currentX = touch.clientX - rect.left;
|
||
const currentY = touch.clientY - rect.top;
|
||
|
||
// 캔버스 초기화 및 이미지 다시 그리기
|
||
workplaceCtx.drawImage(workplaceImage, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
|
||
drawWorkplaceRegions();
|
||
|
||
// 현재 그리는 사각형
|
||
workplaceCtx.strokeStyle = '#3b82f6';
|
||
workplaceCtx.lineWidth = 3;
|
||
workplaceCtx.setLineDash([5, 5]);
|
||
workplaceCtx.strokeRect(
|
||
workplaceStartX,
|
||
workplaceStartY,
|
||
currentX - workplaceStartX,
|
||
currentY - workplaceStartY
|
||
);
|
||
workplaceCtx.setLineDash([]);
|
||
|
||
workplaceCurrentRect = {
|
||
x: workplaceStartX,
|
||
y: workplaceStartY,
|
||
width: currentX - workplaceStartX,
|
||
height: currentY - workplaceStartY
|
||
};
|
||
}
|
||
|
||
// 드래그 시작
|
||
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.setLineDash([5, 5]);
|
||
workplaceCtx.strokeRect(
|
||
workplaceStartX,
|
||
workplaceStartY,
|
||
currentX - workplaceStartX,
|
||
currentY - workplaceStartY
|
||
);
|
||
workplaceCtx.setLineDash([]);
|
||
|
||
// 선택 영역 배경 (반투명)
|
||
workplaceCtx.fillStyle = 'rgba(59, 130, 246, 0.1)';
|
||
workplaceCtx.fillRect(
|
||
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) => {
|
||
// 퍼센트를 픽셀로 변환
|
||
const x = (region.x_percent / 100) * workplaceCanvas.width;
|
||
const y = (region.y_percent / 100) * workplaceCanvas.height;
|
||
const width = (region.width_percent / 100) * workplaceCanvas.width;
|
||
const height = (region.height_percent / 100) * workplaceCanvas.height;
|
||
|
||
// 영역 배경 (반투명)
|
||
workplaceCtx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
||
workplaceCtx.fillRect(x, y, width, height);
|
||
|
||
// 영역 테두리
|
||
workplaceCtx.strokeStyle = '#10b981';
|
||
workplaceCtx.lineWidth = 2;
|
||
workplaceCtx.strokeRect(x, y, width, height);
|
||
|
||
// 영역 이름 표시 (배경 포함)
|
||
const displayName = region.equipment_code ? `[${region.equipment_code}] ${region.equipment_name}` : region.equipment_name;
|
||
workplaceCtx.font = 'bold 12px sans-serif';
|
||
const textMetrics = workplaceCtx.measureText(displayName);
|
||
const textPadding = 4;
|
||
|
||
// 텍스트 배경
|
||
workplaceCtx.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
||
workplaceCtx.fillRect(x + 3, y + 3, textMetrics.width + textPadding * 2, 18);
|
||
|
||
// 텍스트
|
||
workplaceCtx.fillStyle = '#047857';
|
||
workplaceCtx.fillText(displayName, x + 3 + textPadding, y + 16);
|
||
});
|
||
}
|
||
|
||
// 현재 영역 지우기
|
||
function clearWorkplaceCurrentRegion() {
|
||
workplaceCurrentRect = null;
|
||
if (workplaceCanvas && workplaceImage) {
|
||
workplaceCtx.drawImage(workplaceImage, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
|
||
drawWorkplaceRegions();
|
||
}
|
||
}
|
||
|
||
// 설비 위치 저장
|
||
async function saveWorkplaceEquipmentRegion() {
|
||
const equipmentSelect = document.getElementById('equipmentSelectInput');
|
||
const equipmentNameInput = document.getElementById('equipmentNameInput');
|
||
const equipmentCodeInput = document.getElementById('equipmentCodeInput');
|
||
|
||
const selectedEquipmentId = equipmentSelect?.value;
|
||
const newEquipmentName = equipmentNameInput?.value.trim();
|
||
const newEquipmentCode = equipmentCodeInput?.value.trim();
|
||
|
||
// 기존 설비 선택 또는 새 설비 입력 확인
|
||
if (!selectedEquipmentId && (!newEquipmentName || !newEquipmentCode)) {
|
||
showToast('기존 설비를 선택하거나 새 설비 코드와 이름을 입력해주세요.', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!workplaceCurrentRect) {
|
||
showToast('영역을 드래그하여 선택해주세요.', 'warning');
|
||
return;
|
||
}
|
||
|
||
// 퍼센트로 변환 (음수 영역 처리)
|
||
let xPercent = (Math.min(workplaceCurrentRect.x, workplaceCurrentRect.x + workplaceCurrentRect.width) / workplaceCanvas.width) * 100;
|
||
let yPercent = (Math.min(workplaceCurrentRect.y, workplaceCurrentRect.y + workplaceCurrentRect.height) / workplaceCanvas.height) * 100;
|
||
let widthPercent = (Math.abs(workplaceCurrentRect.width) / workplaceCanvas.width) * 100;
|
||
let heightPercent = (Math.abs(workplaceCurrentRect.height) / workplaceCanvas.height) * 100;
|
||
|
||
try {
|
||
let equipmentId = selectedEquipmentId;
|
||
let equipmentName = '';
|
||
|
||
if (selectedEquipmentId) {
|
||
// 기존 설비 - 위치 및 작업장 업데이트
|
||
const response = await window.apiCall(`/equipments/${selectedEquipmentId}/map-position`, 'PATCH', {
|
||
workplace_id: window.currentWorkplaceMapId,
|
||
map_x_percent: xPercent,
|
||
map_y_percent: yPercent,
|
||
map_width_percent: widthPercent,
|
||
map_height_percent: heightPercent
|
||
});
|
||
|
||
if (!response || !response.success) {
|
||
throw new Error(response?.message || '위치 저장 실패');
|
||
}
|
||
|
||
const eq = allEquipments.find(e => e.equipment_id == selectedEquipmentId);
|
||
equipmentName = eq?.equipment_name || '설비';
|
||
} else {
|
||
// 새 설비 생성
|
||
const response = await window.apiCall('/equipments', 'POST', {
|
||
equipment_code: newEquipmentCode,
|
||
equipment_name: newEquipmentName,
|
||
workplace_id: window.currentWorkplaceMapId,
|
||
map_x_percent: xPercent,
|
||
map_y_percent: yPercent,
|
||
map_width_percent: widthPercent,
|
||
map_height_percent: heightPercent,
|
||
status: 'active'
|
||
});
|
||
|
||
if (!response || !response.success) {
|
||
throw new Error(response?.message || '설비 생성 실패');
|
||
}
|
||
|
||
equipmentId = response.data.equipment_id;
|
||
equipmentName = newEquipmentName;
|
||
}
|
||
|
||
// 로컬 배열 업데이트
|
||
const newRegion = {
|
||
equipment_id: equipmentId,
|
||
equipment_name: equipmentName,
|
||
equipment_code: newEquipmentCode || existingEquipments.find(e => e.equipment_id == selectedEquipmentId)?.equipment_code,
|
||
x_percent: xPercent,
|
||
y_percent: yPercent,
|
||
width_percent: widthPercent,
|
||
height_percent: heightPercent
|
||
};
|
||
|
||
// 기존 영역이 있으면 교체, 없으면 추가
|
||
const existingIndex = workplaceEquipmentRegions.findIndex(r => r.equipment_id == equipmentId);
|
||
if (existingIndex >= 0) {
|
||
workplaceEquipmentRegions[existingIndex] = newRegion;
|
||
} else {
|
||
workplaceEquipmentRegions.push(newRegion);
|
||
}
|
||
|
||
// 설비 목록 새로고침 (현재 작업장 + 전체)
|
||
await Promise.all([
|
||
loadWorkplaceEquipments(window.currentWorkplaceMapId),
|
||
loadAllEquipments()
|
||
]);
|
||
|
||
// UI 업데이트
|
||
renderWorkplaceEquipmentList();
|
||
clearWorkplaceCurrentRegion();
|
||
if (equipmentNameInput) equipmentNameInput.value = '';
|
||
if (equipmentCodeInput) equipmentCodeInput.value = '';
|
||
if (equipmentSelect) equipmentSelect.value = '';
|
||
|
||
showToast(`설비 "${equipmentName}" 위치가 저장되었습니다.`, 'success');
|
||
} catch (error) {
|
||
console.error('설비 위치 저장 오류:', error);
|
||
showToast(error.message || '설비 위치 저장 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// 설비 목록 렌더링
|
||
function renderWorkplaceEquipmentList() {
|
||
const listDiv = document.getElementById('workplaceEquipmentList');
|
||
|
||
// 등록된 설비 목록 렌더링
|
||
if (listDiv) {
|
||
if (workplaceEquipmentRegions.length === 0) {
|
||
listDiv.innerHTML = '<p style="color: #94a3b8; text-align: center;">아직 정의된 설비가 없습니다</p>';
|
||
} else {
|
||
let html = '';
|
||
workplaceEquipmentRegions.forEach((region, index) => {
|
||
html += `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: white; border-radius: 8px; margin-bottom: 8px; border: 1px solid #e5e7eb;">
|
||
<div>
|
||
<strong style="color: #1e293b;">${region.equipment_name}</strong>
|
||
<span style="color: #64748b; font-size: 12px; margin-left: 8px;">[${region.equipment_code || '-'}]</span>
|
||
<div style="color: #94a3b8; font-size: 11px; margin-top: 2px;">
|
||
위치: (${region.x_percent.toFixed(1)}%, ${region.y_percent.toFixed(1)}%) | 크기: ${region.width_percent.toFixed(1)}% × ${region.height_percent.toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
<button class="btn-small btn-delete" onclick="removeWorkplaceEquipmentRegion(${region.equipment_id})" title="위치 삭제">
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
`;
|
||
});
|
||
listDiv.innerHTML = html;
|
||
}
|
||
}
|
||
|
||
// 설비 선택 드롭다운 업데이트 (항상 호출되어야 함!)
|
||
updateEquipmentSelectDropdown();
|
||
}
|
||
|
||
// 설비 선택 드롭다운 업데이트
|
||
function updateEquipmentSelectDropdown() {
|
||
const selectEl = document.getElementById('equipmentSelectInput');
|
||
if (!selectEl) {
|
||
console.warn('⚠️ equipmentSelectInput 요소를 찾을 수 없습니다');
|
||
return;
|
||
}
|
||
|
||
console.log(`📋 드롭다운 업데이트: 전체 설비 ${allEquipments.length}개`);
|
||
|
||
// 전체 설비 중에서 아직 어떤 지도에도 배치되지 않은 설비만 표시
|
||
// map_x_percent가 null이면 지도에 배치되지 않은 설비
|
||
const availableEquipments = allEquipments.filter(eq =>
|
||
eq.map_x_percent == null || eq.map_x_percent === ''
|
||
);
|
||
|
||
console.log(`📋 지도에 미배치된 설비: ${availableEquipments.length}개`);
|
||
|
||
// 이 작업장에 이미 배치된 설비는 제외 (현재 작업장에서 방금 배치한 경우)
|
||
const registeredIds = workplaceEquipmentRegions.map(r => r.equipment_id);
|
||
const unregisteredEquipments = availableEquipments.filter(eq => !registeredIds.includes(eq.equipment_id));
|
||
|
||
console.log(`📋 선택 가능한 설비: ${unregisteredEquipments.length}개`);
|
||
|
||
let options = '<option value="">-- 기존 설비 선택 --</option>';
|
||
|
||
if (unregisteredEquipments.length === 0) {
|
||
options += '<option value="" disabled>배치 가능한 설비가 없습니다</option>';
|
||
} else {
|
||
unregisteredEquipments.forEach(eq => {
|
||
const workplaceInfo = eq.workplace_name ? ` (${eq.workplace_name})` : ' (미배정)';
|
||
options += `<option value="${eq.equipment_id}">[${eq.equipment_code || '-'}] ${eq.equipment_name}${workplaceInfo}</option>`;
|
||
});
|
||
}
|
||
|
||
selectEl.innerHTML = options;
|
||
|
||
// 배치 가능한 설비 수 표시
|
||
const countEl = document.getElementById('availableEquipmentCount');
|
||
if (countEl) {
|
||
countEl.textContent = `배치 가능: ${unregisteredEquipments.length}개`;
|
||
}
|
||
}
|
||
|
||
// 설비 영역 삭제 (지도에서만 제거, 설비 자체는 유지)
|
||
async function removeWorkplaceEquipmentRegion(equipmentId) {
|
||
if (!confirm('이 설비의 지도 위치 정보를 삭제하시겠습니까?\n(설비 자체는 삭제되지 않습니다)')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// API로 지도 위치 초기화
|
||
const response = await window.apiCall(`/equipments/${equipmentId}/map-position`, 'PATCH', {
|
||
map_x_percent: null,
|
||
map_y_percent: null,
|
||
map_width_percent: null,
|
||
map_height_percent: null
|
||
});
|
||
|
||
if (!response || !response.success) {
|
||
throw new Error(response?.message || '위치 삭제 실패');
|
||
}
|
||
|
||
// 로컬 배열에서 제거
|
||
workplaceEquipmentRegions = workplaceEquipmentRegions.filter(r => r.equipment_id != equipmentId);
|
||
|
||
// 설비 목록 새로고침 (현재 작업장 + 전체)
|
||
await Promise.all([
|
||
loadWorkplaceEquipments(window.currentWorkplaceMapId),
|
||
loadAllEquipments()
|
||
]);
|
||
|
||
renderWorkplaceEquipmentList();
|
||
|
||
// 캔버스 다시 그리기
|
||
if (workplaceCanvas && workplaceImage) {
|
||
workplaceCtx.drawImage(workplaceImage, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
|
||
drawWorkplaceRegions();
|
||
}
|
||
|
||
showToast('설비 위치가 삭제되었습니다.', 'success');
|
||
} catch (error) {
|
||
console.error('설비 위치 삭제 오류:', error);
|
||
showToast(error.message || '설비 위치 삭제 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// 작업장 레이아웃 이미지 미리보기
|
||
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;
|
||
window.saveCategory = saveCategory;
|
||
window.deleteCategory = deleteCategory;
|
||
window.openWorkplaceModal = openWorkplaceModal;
|
||
window.closeWorkplaceModal = closeWorkplaceModal;
|
||
window.editWorkplace = editWorkplace;
|
||
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;
|
||
window.toggleNewEquipmentFields = toggleNewEquipmentFields;
|
||
|
||
// 새 설비 필드 토글 (기존 설비 선택 시 숨김)
|
||
function toggleNewEquipmentFields() {
|
||
const selectEl = document.getElementById('equipmentSelectInput');
|
||
const fieldsDiv = document.getElementById('newEquipmentFields');
|
||
|
||
if (selectEl && fieldsDiv) {
|
||
if (selectEl.value) {
|
||
fieldsDiv.style.display = 'none';
|
||
} else {
|
||
fieldsDiv.style.display = 'block';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 전체화면 설비 배치 편집기 ====================
|
||
|
||
// 전체화면 편집기 관련 전역 변수
|
||
let fsCanvas = null;
|
||
let fsCtx = null;
|
||
let fsImage = null;
|
||
let fsIsDrawing = false;
|
||
let fsStartX = 0;
|
||
let fsStartY = 0;
|
||
let fsCurrentRect = null;
|
||
let fsSidebarVisible = true;
|
||
|
||
// 전체화면 편집기 열기
|
||
async function openFullscreenEquipmentEditor() {
|
||
const workplaceId = window.currentWorkplaceMapId;
|
||
if (!workplaceId) {
|
||
showToast('작업장을 먼저 선택해주세요.', 'warning');
|
||
return;
|
||
}
|
||
|
||
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||
if (!workplace) {
|
||
showToast('작업장 정보를 찾을 수 없습니다.', 'error');
|
||
return;
|
||
}
|
||
|
||
// 레이아웃 이미지 확인
|
||
if (!workplace.layout_image) {
|
||
showToast('작업장 레이아웃 이미지를 먼저 업로드해주세요.', 'warning');
|
||
return;
|
||
}
|
||
|
||
// 전체화면 에디터 타이틀 설정
|
||
const titleEl = document.getElementById('fullscreenEditorTitle');
|
||
if (titleEl) {
|
||
titleEl.textContent = `${workplace.workplace_name} - 설비 위치 편집`;
|
||
}
|
||
|
||
// 전체화면 에디터 표시
|
||
const editor = document.getElementById('fullscreenEquipmentEditor');
|
||
if (editor) {
|
||
editor.style.display = 'flex';
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
// 이미지 URL 생성
|
||
const fullImageUrl = workplace.layout_image.startsWith('http')
|
||
? workplace.layout_image
|
||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
|
||
|
||
// 캔버스 초기화
|
||
initFullscreenCanvas(fullImageUrl);
|
||
|
||
// 설비 목록 로드 및 드롭다운 업데이트
|
||
await Promise.all([
|
||
loadWorkplaceEquipments(workplaceId),
|
||
loadAllEquipments()
|
||
]);
|
||
updateFsEquipmentUI();
|
||
}
|
||
|
||
// 전체화면 편집기 닫기
|
||
function closeFullscreenEditor() {
|
||
const editor = document.getElementById('fullscreenEquipmentEditor');
|
||
if (editor) {
|
||
editor.style.display = 'none';
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
// 전역 상태 초기화
|
||
fsCanvas = null;
|
||
fsCtx = null;
|
||
fsImage = null;
|
||
fsIsDrawing = false;
|
||
fsCurrentRect = null;
|
||
|
||
// 작업장 지도 모달의 설비 목록도 업데이트
|
||
renderWorkplaceEquipmentList();
|
||
|
||
// 작업장 카드 목록 새로고침 (썸네일 업데이트)
|
||
renderWorkplaces();
|
||
}
|
||
|
||
// 사이드바 토글
|
||
function toggleEditorSidebar() {
|
||
const sidebar = document.getElementById('fullscreenSidebar');
|
||
const canvasArea = document.getElementById('fullscreenCanvasArea');
|
||
const toggleIcon = document.getElementById('sidebarToggleIcon');
|
||
|
||
if (!sidebar || !canvasArea) return;
|
||
|
||
fsSidebarVisible = !fsSidebarVisible;
|
||
|
||
if (fsSidebarVisible) {
|
||
sidebar.style.display = 'flex';
|
||
toggleIcon.textContent = '▶';
|
||
} else {
|
||
sidebar.style.display = 'none';
|
||
toggleIcon.textContent = '◀';
|
||
}
|
||
|
||
// 캔버스 크기 재조정
|
||
if (fsImage) {
|
||
setTimeout(() => {
|
||
resizeFullscreenCanvas();
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// 전체화면 캔버스 초기화
|
||
function initFullscreenCanvas(imageUrl) {
|
||
const img = new Image();
|
||
img.onload = function() {
|
||
fsImage = img;
|
||
fsCanvas = document.getElementById('fullscreenRegionCanvas');
|
||
if (!fsCanvas) return;
|
||
|
||
fsCtx = fsCanvas.getContext('2d');
|
||
|
||
// 캔버스 크기 조정
|
||
resizeFullscreenCanvas();
|
||
|
||
// 이벤트 리스너 등록
|
||
fsCanvas.onmousedown = startFsDraw;
|
||
fsCanvas.onmousemove = drawFsRegion;
|
||
fsCanvas.onmouseup = endFsDraw;
|
||
fsCanvas.onmouseleave = endFsDraw;
|
||
|
||
// 터치 이벤트 지원
|
||
fsCanvas.ontouchstart = handleFsTouchStart;
|
||
fsCanvas.ontouchmove = handleFsTouchMove;
|
||
fsCanvas.ontouchend = endFsDraw;
|
||
|
||
console.log('✅ 전체화면 캔버스 초기화 완료');
|
||
};
|
||
|
||
img.onerror = function() {
|
||
console.error('전체화면 이미지 로드 실패:', imageUrl);
|
||
showToast('이미지를 불러올 수 없습니다.', 'error');
|
||
};
|
||
|
||
img.src = imageUrl;
|
||
}
|
||
|
||
// 캔버스 크기 조정 (화면에 맞게)
|
||
function resizeFullscreenCanvas() {
|
||
if (!fsImage || !fsCanvas) return;
|
||
|
||
const wrapper = document.getElementById('fullscreenCanvasWrapper');
|
||
if (!wrapper) return;
|
||
|
||
// 사용 가능한 영역 계산
|
||
const maxWidth = wrapper.clientWidth - 40;
|
||
const maxHeight = wrapper.clientHeight - 40;
|
||
|
||
// 이미지 비율 유지하면서 크기 조정
|
||
const imgRatio = fsImage.width / fsImage.height;
|
||
const containerRatio = maxWidth / maxHeight;
|
||
|
||
let scale;
|
||
if (imgRatio > containerRatio) {
|
||
// 이미지가 더 넓음 - 너비 기준
|
||
scale = maxWidth / fsImage.width;
|
||
} else {
|
||
// 이미지가 더 높음 - 높이 기준
|
||
scale = maxHeight / fsImage.height;
|
||
}
|
||
|
||
// 최소/최대 스케일 제한
|
||
scale = Math.min(Math.max(scale, 0.1), 2);
|
||
|
||
fsCanvas.width = fsImage.width * scale;
|
||
fsCanvas.height = fsImage.height * scale;
|
||
|
||
// 이미지 그리기
|
||
fsCtx.drawImage(fsImage, 0, 0, fsCanvas.width, fsCanvas.height);
|
||
|
||
// 기존 영역 그리기
|
||
drawFsRegions();
|
||
|
||
// 줌 정보 업데이트
|
||
const zoomInfo = document.getElementById('canvasZoomInfo');
|
||
if (zoomInfo) {
|
||
zoomInfo.textContent = `${Math.round(scale * 100)}%`;
|
||
}
|
||
}
|
||
|
||
// 드래그 시작
|
||
function startFsDraw(e) {
|
||
fsIsDrawing = true;
|
||
const rect = fsCanvas.getBoundingClientRect();
|
||
fsStartX = e.clientX - rect.left;
|
||
fsStartY = e.clientY - rect.top;
|
||
}
|
||
|
||
// 드래그 중
|
||
function drawFsRegion(e) {
|
||
if (!fsIsDrawing) return;
|
||
|
||
const rect = fsCanvas.getBoundingClientRect();
|
||
const currentX = e.clientX - rect.left;
|
||
const currentY = e.clientY - rect.top;
|
||
|
||
// 캔버스 초기화 및 이미지 다시 그리기
|
||
fsCtx.drawImage(fsImage, 0, 0, fsCanvas.width, fsCanvas.height);
|
||
|
||
// 기존 영역들 표시
|
||
drawFsRegions();
|
||
|
||
// 현재 그리는 사각형 (점선)
|
||
fsCtx.strokeStyle = '#3b82f6';
|
||
fsCtx.lineWidth = 3;
|
||
fsCtx.setLineDash([8, 4]);
|
||
fsCtx.strokeRect(
|
||
fsStartX,
|
||
fsStartY,
|
||
currentX - fsStartX,
|
||
currentY - fsStartY
|
||
);
|
||
fsCtx.setLineDash([]);
|
||
|
||
// 선택 영역 배경
|
||
fsCtx.fillStyle = 'rgba(59, 130, 246, 0.15)';
|
||
fsCtx.fillRect(
|
||
fsStartX,
|
||
fsStartY,
|
||
currentX - fsStartX,
|
||
currentY - fsStartY
|
||
);
|
||
|
||
// 영역 정보 표시
|
||
const width = Math.abs(currentX - fsStartX);
|
||
const height = Math.abs(currentY - fsStartY);
|
||
fsCtx.font = 'bold 12px sans-serif';
|
||
fsCtx.fillStyle = '#3b82f6';
|
||
fsCtx.fillText(`${Math.round(width)} × ${Math.round(height)}px`, Math.min(fsStartX, currentX) + 5, Math.min(fsStartY, currentY) - 5);
|
||
|
||
fsCurrentRect = {
|
||
x: fsStartX,
|
||
y: fsStartY,
|
||
width: currentX - fsStartX,
|
||
height: currentY - fsStartY
|
||
};
|
||
}
|
||
|
||
// 드래그 종료
|
||
function endFsDraw(e) {
|
||
fsIsDrawing = false;
|
||
}
|
||
|
||
// 터치 이벤트 핸들러
|
||
function handleFsTouchStart(e) {
|
||
e.preventDefault();
|
||
const touch = e.touches[0];
|
||
const rect = fsCanvas.getBoundingClientRect();
|
||
fsIsDrawing = true;
|
||
fsStartX = touch.clientX - rect.left;
|
||
fsStartY = touch.clientY - rect.top;
|
||
}
|
||
|
||
function handleFsTouchMove(e) {
|
||
e.preventDefault();
|
||
if (!fsIsDrawing) return;
|
||
|
||
const touch = e.touches[0];
|
||
const rect = fsCanvas.getBoundingClientRect();
|
||
const currentX = touch.clientX - rect.left;
|
||
const currentY = touch.clientY - rect.top;
|
||
|
||
// 캔버스 초기화 및 이미지 다시 그리기
|
||
fsCtx.drawImage(fsImage, 0, 0, fsCanvas.width, fsCanvas.height);
|
||
drawFsRegions();
|
||
|
||
// 현재 그리는 사각형
|
||
fsCtx.strokeStyle = '#3b82f6';
|
||
fsCtx.lineWidth = 3;
|
||
fsCtx.setLineDash([8, 4]);
|
||
fsCtx.strokeRect(
|
||
fsStartX,
|
||
fsStartY,
|
||
currentX - fsStartX,
|
||
currentY - fsStartY
|
||
);
|
||
fsCtx.setLineDash([]);
|
||
|
||
fsCtx.fillStyle = 'rgba(59, 130, 246, 0.15)';
|
||
fsCtx.fillRect(
|
||
fsStartX,
|
||
fsStartY,
|
||
currentX - fsStartX,
|
||
currentY - fsStartY
|
||
);
|
||
|
||
fsCurrentRect = {
|
||
x: fsStartX,
|
||
y: fsStartY,
|
||
width: currentX - fsStartX,
|
||
height: currentY - fsStartY
|
||
};
|
||
}
|
||
|
||
// 기존 영역들 그리기
|
||
function drawFsRegions() {
|
||
workplaceEquipmentRegions.forEach((region, index) => {
|
||
// 퍼센트를 픽셀로 변환
|
||
const x = (region.x_percent / 100) * fsCanvas.width;
|
||
const y = (region.y_percent / 100) * fsCanvas.height;
|
||
const width = (region.width_percent / 100) * fsCanvas.width;
|
||
const height = (region.height_percent / 100) * fsCanvas.height;
|
||
|
||
// 영역 배경 (반투명)
|
||
fsCtx.fillStyle = 'rgba(16, 185, 129, 0.2)';
|
||
fsCtx.fillRect(x, y, width, height);
|
||
|
||
// 영역 테두리
|
||
fsCtx.strokeStyle = '#10b981';
|
||
fsCtx.lineWidth = 2;
|
||
fsCtx.strokeRect(x, y, width, height);
|
||
|
||
// 영역 이름 표시
|
||
const displayName = region.equipment_code
|
||
? `[${region.equipment_code}] ${region.equipment_name}`
|
||
: region.equipment_name;
|
||
fsCtx.font = 'bold 13px sans-serif';
|
||
const textMetrics = fsCtx.measureText(displayName);
|
||
const textPadding = 6;
|
||
|
||
// 텍스트 배경
|
||
fsCtx.fillStyle = 'rgba(255, 255, 255, 0.95)';
|
||
fsCtx.fillRect(x + 4, y + 4, textMetrics.width + textPadding * 2, 22);
|
||
|
||
// 텍스트
|
||
fsCtx.fillStyle = '#047857';
|
||
fsCtx.fillText(displayName, x + 4 + textPadding, y + 19);
|
||
});
|
||
}
|
||
|
||
// 현재 영역 지우기
|
||
function fsClearCurrentRegion() {
|
||
fsCurrentRect = null;
|
||
if (fsCanvas && fsImage) {
|
||
fsCtx.drawImage(fsImage, 0, 0, fsCanvas.width, fsCanvas.height);
|
||
drawFsRegions();
|
||
}
|
||
showToast('영역이 초기화되었습니다.', 'info');
|
||
}
|
||
|
||
// 설비 영역 저장 (전체화면 편집기용)
|
||
async function fsSaveEquipmentRegion() {
|
||
const equipmentSelect = document.getElementById('fsEquipmentSelect');
|
||
const equipmentNameInput = document.getElementById('fsEquipmentName');
|
||
const equipmentCodeInput = document.getElementById('fsEquipmentCode');
|
||
|
||
const selectedEquipmentId = equipmentSelect?.value;
|
||
const newEquipmentName = equipmentNameInput?.value.trim();
|
||
const newEquipmentCode = equipmentCodeInput?.value.trim();
|
||
|
||
// 기존 설비 선택 또는 새 설비 입력 확인
|
||
if (!selectedEquipmentId && (!newEquipmentName || !newEquipmentCode)) {
|
||
showToast('기존 설비를 선택하거나 새 설비 코드와 이름을 입력해주세요.', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!fsCurrentRect) {
|
||
showToast('지도에서 영역을 드래그하여 선택해주세요.', 'warning');
|
||
return;
|
||
}
|
||
|
||
// 퍼센트로 변환 (음수 영역 처리)
|
||
let xPercent = (Math.min(fsCurrentRect.x, fsCurrentRect.x + fsCurrentRect.width) / fsCanvas.width) * 100;
|
||
let yPercent = (Math.min(fsCurrentRect.y, fsCurrentRect.y + fsCurrentRect.height) / fsCanvas.height) * 100;
|
||
let widthPercent = (Math.abs(fsCurrentRect.width) / fsCanvas.width) * 100;
|
||
let heightPercent = (Math.abs(fsCurrentRect.height) / fsCanvas.height) * 100;
|
||
|
||
try {
|
||
let equipmentId = selectedEquipmentId;
|
||
let equipmentName = '';
|
||
let equipmentCode = '';
|
||
|
||
if (selectedEquipmentId) {
|
||
// 기존 설비 - 위치 및 작업장 업데이트
|
||
const response = await window.apiCall(`/equipments/${selectedEquipmentId}/map-position`, 'PATCH', {
|
||
workplace_id: window.currentWorkplaceMapId,
|
||
map_x_percent: xPercent,
|
||
map_y_percent: yPercent,
|
||
map_width_percent: widthPercent,
|
||
map_height_percent: heightPercent
|
||
});
|
||
|
||
if (!response || !response.success) {
|
||
throw new Error(response?.message || '위치 저장 실패');
|
||
}
|
||
|
||
const eq = allEquipments.find(e => e.equipment_id == selectedEquipmentId);
|
||
equipmentName = eq?.equipment_name || '설비';
|
||
equipmentCode = eq?.equipment_code || '';
|
||
} else {
|
||
// 새 설비 생성
|
||
const response = await window.apiCall('/equipments', 'POST', {
|
||
equipment_code: newEquipmentCode,
|
||
equipment_name: newEquipmentName,
|
||
workplace_id: window.currentWorkplaceMapId,
|
||
map_x_percent: xPercent,
|
||
map_y_percent: yPercent,
|
||
map_width_percent: widthPercent,
|
||
map_height_percent: heightPercent,
|
||
status: 'active'
|
||
});
|
||
|
||
if (!response || !response.success) {
|
||
throw new Error(response?.message || '설비 생성 실패');
|
||
}
|
||
|
||
equipmentId = response.data.equipment_id;
|
||
equipmentName = newEquipmentName;
|
||
equipmentCode = newEquipmentCode;
|
||
}
|
||
|
||
// 로컬 배열 업데이트
|
||
const newRegion = {
|
||
equipment_id: equipmentId,
|
||
equipment_name: equipmentName,
|
||
equipment_code: equipmentCode,
|
||
x_percent: xPercent,
|
||
y_percent: yPercent,
|
||
width_percent: widthPercent,
|
||
height_percent: heightPercent
|
||
};
|
||
|
||
// 기존 영역이 있으면 교체, 없으면 추가
|
||
const existingIndex = workplaceEquipmentRegions.findIndex(r => r.equipment_id == equipmentId);
|
||
if (existingIndex >= 0) {
|
||
workplaceEquipmentRegions[existingIndex] = newRegion;
|
||
} else {
|
||
workplaceEquipmentRegions.push(newRegion);
|
||
}
|
||
|
||
// 설비 목록 새로고침
|
||
await Promise.all([
|
||
loadWorkplaceEquipments(window.currentWorkplaceMapId),
|
||
loadAllEquipments()
|
||
]);
|
||
|
||
// 입력 필드 초기화 (UI 업데이트 전에 먼저)
|
||
if (equipmentNameInput) equipmentNameInput.value = '';
|
||
if (equipmentCodeInput) equipmentCodeInput.value = ''; // 비워야 다음 코드 자동 로드됨
|
||
if (equipmentSelect) equipmentSelect.value = '';
|
||
|
||
// UI 업데이트 및 다음 관리번호 로드
|
||
await updateFsEquipmentUI();
|
||
fsClearCurrentRegion();
|
||
|
||
showToast(`설비 "${equipmentName}" 위치가 저장되었습니다.`, 'success');
|
||
} catch (error) {
|
||
console.error('설비 위치 저장 오류:', error);
|
||
showToast(error.message || '설비 위치 저장 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// 새 설비 필드 토글 (전체화면 편집기용)
|
||
function fsToggleNewEquipmentFields() {
|
||
const selectEl = document.getElementById('fsEquipmentSelect');
|
||
const fieldsDiv = document.getElementById('fsNewEquipmentFields');
|
||
|
||
if (selectEl && fieldsDiv) {
|
||
if (selectEl.value) {
|
||
fieldsDiv.style.opacity = '0.5';
|
||
fieldsDiv.querySelector('input')?.setAttribute('disabled', 'true');
|
||
document.getElementById('fsEquipmentCode')?.setAttribute('disabled', 'true');
|
||
document.getElementById('fsEquipmentName')?.setAttribute('disabled', 'true');
|
||
} else {
|
||
fieldsDiv.style.opacity = '1';
|
||
document.getElementById('fsEquipmentCode')?.removeAttribute('disabled');
|
||
document.getElementById('fsEquipmentName')?.removeAttribute('disabled');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 다음 관리번호 로드 (전체화면 편집기용)
|
||
async function loadNextEquipmentCodeForFs() {
|
||
try {
|
||
const response = await window.apiCall('/equipments/next-code', 'GET');
|
||
if (response && response.success) {
|
||
const codeInput = document.getElementById('fsEquipmentCode');
|
||
if (codeInput && !codeInput.value) {
|
||
codeInput.value = response.data.next_code;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('다음 관리번호 조회 실패:', error);
|
||
// 오류 시 무시 (사용자가 직접 입력)
|
||
}
|
||
}
|
||
|
||
// 전체화면 편집기 UI 업데이트
|
||
async function updateFsEquipmentUI() {
|
||
// 설비 선택 드롭다운 업데이트
|
||
const selectEl = document.getElementById('fsEquipmentSelect');
|
||
if (selectEl) {
|
||
// 지도에 미배치된 설비만 표시
|
||
const availableEquipments = allEquipments.filter(eq =>
|
||
eq.map_x_percent == null || eq.map_x_percent === ''
|
||
);
|
||
|
||
// 이미 이 작업장에 배치된 설비 제외
|
||
const registeredIds = workplaceEquipmentRegions.map(r => r.equipment_id);
|
||
const unregisteredEquipments = availableEquipments.filter(eq => !registeredIds.includes(eq.equipment_id));
|
||
|
||
let options = '<option value="">-- 기존 설비 선택 --</option>';
|
||
|
||
if (unregisteredEquipments.length === 0) {
|
||
options += '<option value="" disabled>배치 가능한 설비가 없습니다</option>';
|
||
} else {
|
||
unregisteredEquipments.forEach(eq => {
|
||
options += `<option value="${eq.equipment_id}">[${eq.equipment_code || '-'}] ${eq.equipment_name}</option>`;
|
||
});
|
||
}
|
||
|
||
selectEl.innerHTML = options;
|
||
|
||
// 배치 가능한 설비 수 표시
|
||
const countEl = document.getElementById('fsAvailableEquipmentCount');
|
||
if (countEl) {
|
||
countEl.textContent = `${unregisteredEquipments.length}개`;
|
||
}
|
||
}
|
||
|
||
// 새 설비 코드 자동 생성
|
||
await loadNextEquipmentCodeForFs();
|
||
|
||
// 등록된 설비 목록 업데이트
|
||
const listEl = document.getElementById('fsEquipmentList');
|
||
if (listEl) {
|
||
if (workplaceEquipmentRegions.length === 0) {
|
||
listEl.innerHTML = '<p class="empty-message">등록된 설비가 없습니다</p>';
|
||
} else {
|
||
let html = '';
|
||
workplaceEquipmentRegions.forEach((region) => {
|
||
html += `
|
||
<div class="equipment-list-item">
|
||
<div class="equipment-info">
|
||
<strong>${region.equipment_name}</strong>
|
||
<span class="equipment-code">[${region.equipment_code || '-'}]</span>
|
||
<div class="equipment-position">
|
||
위치: (${region.x_percent.toFixed(1)}%, ${region.y_percent.toFixed(1)}%)
|
||
</div>
|
||
</div>
|
||
<button class="btn-delete-sm" onclick="fsRemoveEquipmentRegion(${region.equipment_id})" title="삭제">
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
`;
|
||
});
|
||
listEl.innerHTML = html;
|
||
}
|
||
|
||
// 등록된 설비 수 표시
|
||
const registeredCountEl = document.getElementById('fsRegisteredCount');
|
||
if (registeredCountEl) {
|
||
registeredCountEl.textContent = `${workplaceEquipmentRegions.length}개`;
|
||
}
|
||
}
|
||
|
||
// 작업장 지도 모달의 설비 수도 업데이트
|
||
const modalCountEl = document.getElementById('workplaceEquipmentCount');
|
||
if (modalCountEl) {
|
||
modalCountEl.textContent = `${workplaceEquipmentRegions.length}개`;
|
||
}
|
||
}
|
||
|
||
// 설비 영역 삭제 (전체화면 편집기용)
|
||
async function fsRemoveEquipmentRegion(equipmentId) {
|
||
if (!confirm('이 설비의 지도 위치를 삭제하시겠습니까?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// API로 지도 위치 초기화
|
||
const response = await window.apiCall(`/equipments/${equipmentId}/map-position`, 'PATCH', {
|
||
map_x_percent: null,
|
||
map_y_percent: null,
|
||
map_width_percent: null,
|
||
map_height_percent: null
|
||
});
|
||
|
||
if (!response || !response.success) {
|
||
throw new Error(response?.message || '위치 삭제 실패');
|
||
}
|
||
|
||
// 로컬 배열에서 제거
|
||
workplaceEquipmentRegions = workplaceEquipmentRegions.filter(r => r.equipment_id != equipmentId);
|
||
|
||
// 설비 목록 새로고침
|
||
await Promise.all([
|
||
loadWorkplaceEquipments(window.currentWorkplaceMapId),
|
||
loadAllEquipments()
|
||
]);
|
||
|
||
// UI 업데이트
|
||
updateFsEquipmentUI();
|
||
|
||
// 캔버스 다시 그리기
|
||
if (fsCanvas && fsImage) {
|
||
fsCtx.drawImage(fsImage, 0, 0, fsCanvas.width, fsCanvas.height);
|
||
drawFsRegions();
|
||
}
|
||
|
||
showToast('설비 위치가 삭제되었습니다.', 'success');
|
||
} catch (error) {
|
||
console.error('설비 위치 삭제 오류:', error);
|
||
showToast(error.message || '삭제 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// 전체화면 편집기 함수들 전역 노출
|
||
window.openFullscreenEquipmentEditor = openFullscreenEquipmentEditor;
|
||
window.closeFullscreenEditor = closeFullscreenEditor;
|
||
window.toggleEditorSidebar = toggleEditorSidebar;
|
||
window.fsClearCurrentRegion = fsClearCurrentRegion;
|
||
window.fsSaveEquipmentRegion = fsSaveEquipmentRegion;
|
||
window.fsToggleNewEquipmentFields = fsToggleNewEquipmentFields;
|
||
window.fsRemoveEquipmentRegion = fsRemoveEquipmentRegion;
|