refactor: 코드 분리 + 성능 최적화 + 모바일 개선
tkqc 5개 페이지 인라인 JS/CSS를 외부 파일로 추출 (HTML 82% 감소) tkuser index.html을 CSS 1개 + JS 10개 모듈로 분리 (3283→1155줄) - 공통 유틸 추출: issue-helpers, photo-modal, toast - 공통 CSS 확장: tkqc-common.css (모바일 반응형 포함) - 모바일 하단 네비게이션 추가 (mobile-bottom-nav.js) - nginx: JS/CSS 1시간 캐싱 + gzip 압축 활성화 - Tailwind CDN preload, 캐시버스터 통일 (?v=20260213) - 카메라 capture="environment" 추가 - tkuser Dockerfile에 static/ 디렉토리 복사 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
315
user-management/web/static/js/tkuser-layout-map.js
Normal file
315
user-management/web/static/js/tkuser-layout-map.js
Normal file
@@ -0,0 +1,315 @@
|
||||
/* ===== Layout Map (구역지도) ===== */
|
||||
let layoutMapImage = null;
|
||||
let mapRegions = [];
|
||||
let mapCanvas = null;
|
||||
let mapCtx = null;
|
||||
let isDrawing = false;
|
||||
let drawStartX = 0;
|
||||
let drawStartY = 0;
|
||||
let currentRect = null;
|
||||
let selectedMapCategoryId = null;
|
||||
|
||||
// 구역지도 프리뷰 캔버스 클릭 -> 해당 영역의 작업장으로 드릴다운
|
||||
document.getElementById('previewCanvas')?.addEventListener('click', function(e) {
|
||||
if (!previewMapRegions.length) return;
|
||||
const rect = this.getBoundingClientRect();
|
||||
const xPct = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const yPct = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
for (const region of previewMapRegions) {
|
||||
if (xPct >= region.x_start && xPct <= region.x_end && yPct >= region.y_start && yPct <= region.y_end) {
|
||||
const wp = workplaces.find(w => w.workplace_id === region.workplace_id);
|
||||
if (wp) {
|
||||
selectWorkplaceForEquipments(wp.workplace_id, wp.workplace_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function loadLayoutPreview(categoryId) {
|
||||
const cat = workplaceCategories.find(c => c.category_id == categoryId);
|
||||
if (!cat || !cat.layout_image) {
|
||||
document.getElementById('layoutPreviewArea').classList.remove('hidden');
|
||||
document.getElementById('layoutPreviewCanvas').classList.add('hidden');
|
||||
document.getElementById('layoutPreviewArea').innerHTML = '<i class="fas fa-image text-3xl mb-2"></i><p>레이아웃 이미지가 없습니다. "지도 설정"에서 업로드하세요.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('layoutPreviewArea').classList.add('hidden');
|
||||
document.getElementById('layoutPreviewCanvas').classList.remove('hidden');
|
||||
|
||||
const pCanvas = document.getElementById('previewCanvas');
|
||||
const pCtx = pCanvas.getContext('2d');
|
||||
|
||||
const imgUrl = cat.layout_image.startsWith('http') ? cat.layout_image : '/uploads/' + cat.layout_image.replace(/^\/uploads\//, '');
|
||||
|
||||
const img = new Image();
|
||||
img.onload = async function() {
|
||||
const maxW = 800;
|
||||
const scale = img.width > maxW ? maxW / img.width : 1;
|
||||
pCanvas.width = img.width * scale;
|
||||
pCanvas.height = img.height * scale;
|
||||
pCtx.drawImage(img, 0, 0, pCanvas.width, pCanvas.height);
|
||||
|
||||
try {
|
||||
const r = await api(`/workplaces/categories/${categoryId}/map-regions`);
|
||||
const regions = r.data || [];
|
||||
previewMapRegions = regions;
|
||||
regions.forEach(region => {
|
||||
const x1 = (region.x_start / 100) * pCanvas.width;
|
||||
const y1 = (region.y_start / 100) * pCanvas.height;
|
||||
const x2 = (region.x_end / 100) * pCanvas.width;
|
||||
const y2 = (region.y_end / 100) * pCanvas.height;
|
||||
pCtx.strokeStyle = '#10b981';
|
||||
pCtx.lineWidth = 2;
|
||||
pCtx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
pCtx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
||||
pCtx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||||
pCtx.fillStyle = '#10b981';
|
||||
pCtx.font = '14px sans-serif';
|
||||
pCtx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
||||
});
|
||||
} catch(e) { console.warn('영역 로드 실패:', e); }
|
||||
};
|
||||
img.src = imgUrl;
|
||||
}
|
||||
|
||||
// 구역지도 모달
|
||||
function openLayoutMapModal() {
|
||||
if (!selectedMapCategoryId) {
|
||||
showToast('공장을 먼저 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('layoutMapModal');
|
||||
mapCanvas = document.getElementById('regionCanvas');
|
||||
mapCtx = mapCanvas.getContext('2d');
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
loadLayoutMapData();
|
||||
updateRegionWorkplaceSelect();
|
||||
}
|
||||
|
||||
function closeLayoutMapModal() {
|
||||
const modal = document.getElementById('layoutMapModal');
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
if (mapCanvas) {
|
||||
mapCanvas.removeEventListener('mousedown', onCanvasMouseDown);
|
||||
mapCanvas.removeEventListener('mousemove', onCanvasMouseMove);
|
||||
mapCanvas.removeEventListener('mouseup', onCanvasMouseUp);
|
||||
mapCanvas.removeEventListener('mouseleave', onCanvasMouseUp);
|
||||
}
|
||||
currentRect = null;
|
||||
if (selectedMapCategoryId) loadLayoutPreview(selectedMapCategoryId);
|
||||
}
|
||||
|
||||
async function loadLayoutMapData() {
|
||||
try {
|
||||
const cat = workplaceCategories.find(c => c.category_id == selectedMapCategoryId);
|
||||
if (!cat) return;
|
||||
|
||||
const imgDiv = document.getElementById('currentLayoutImage');
|
||||
if (cat.layout_image) {
|
||||
const imgUrl = cat.layout_image.startsWith('http') ? cat.layout_image : '/uploads/' + cat.layout_image.replace(/^\/uploads\//, '');
|
||||
imgDiv.innerHTML = `<img src="${imgUrl}" style="max-width:100%;max-height:300px;border-radius:4px;" alt="레이아웃">`;
|
||||
loadImageToCanvas(imgUrl);
|
||||
} else {
|
||||
imgDiv.innerHTML = '<span class="text-sm text-gray-400">업로드된 이미지가 없습니다</span>';
|
||||
}
|
||||
|
||||
const r = await api(`/workplaces/categories/${selectedMapCategoryId}/map-regions`);
|
||||
mapRegions = r.data || [];
|
||||
renderRegionList();
|
||||
} catch(e) {
|
||||
console.error('레이아웃 데이터 로딩 오류:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadImageToCanvas(imgUrl) {
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
const maxW = 800;
|
||||
const scale = img.width > maxW ? maxW / img.width : 1;
|
||||
mapCanvas.width = img.width * scale;
|
||||
mapCanvas.height = img.height * scale;
|
||||
mapCtx.drawImage(img, 0, 0, mapCanvas.width, mapCanvas.height);
|
||||
layoutMapImage = img;
|
||||
drawExistingRegions();
|
||||
setupCanvasEvents();
|
||||
};
|
||||
img.src = imgUrl;
|
||||
}
|
||||
|
||||
function updateRegionWorkplaceSelect() {
|
||||
const sel = document.getElementById('regionWorkplaceSelect');
|
||||
if (!sel) return;
|
||||
const catWps = workplaces.filter(w => w.category_id == selectedMapCategoryId);
|
||||
let html = '<option value="">작업장을 선택하세요</option>';
|
||||
catWps.forEach(wp => {
|
||||
const hasRegion = mapRegions.some(r => r.workplace_id === wp.workplace_id);
|
||||
html += `<option value="${wp.workplace_id}">${wp.workplace_name}${hasRegion ? ' (영역 정의됨)' : ''}</option>`;
|
||||
});
|
||||
sel.innerHTML = html;
|
||||
}
|
||||
|
||||
function previewLayoutImage(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('currentLayoutImage').innerHTML = `
|
||||
<img src="${e.target.result}" style="max-width:100%;max-height:300px;border-radius:4px;" alt="미리보기">
|
||||
<p class="text-xs text-gray-400 mt-1">미리보기 (저장하려면 "이미지 업로드" 버튼 클릭)</p>`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
async function uploadLayoutImage() {
|
||||
const file = document.getElementById('layoutImageFile').files[0];
|
||||
if (!file) { showToast('이미지를 선택해주세요.', 'error'); return; }
|
||||
if (!selectedMapCategoryId) { showToast('공장을 먼저 선택해주세요.', 'error'); return; }
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/workplaces/categories/${selectedMapCategoryId}/layout-image`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': token ? `Bearer ${token}` : '' },
|
||||
body: fd
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || '업로드 실패');
|
||||
|
||||
showToast('이미지가 업로드되었습니다.');
|
||||
const imgUrl = '/uploads/' + result.data.image_path.replace(/^\/uploads\//, '');
|
||||
document.getElementById('currentLayoutImage').innerHTML = `<img src="${imgUrl}" style="max-width:100%;max-height:300px;border-radius:4px;" alt="레이아웃">`;
|
||||
loadImageToCanvas(imgUrl);
|
||||
|
||||
// 카테고리 데이터 갱신
|
||||
await loadWorkplaceCategories();
|
||||
} catch(e) {
|
||||
showToast(e.message || '업로드 실패', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 캔버스 드로잉
|
||||
function setupCanvasEvents() {
|
||||
mapCanvas.removeEventListener('mousedown', onCanvasMouseDown);
|
||||
mapCanvas.removeEventListener('mousemove', onCanvasMouseMove);
|
||||
mapCanvas.removeEventListener('mouseup', onCanvasMouseUp);
|
||||
mapCanvas.removeEventListener('mouseleave', onCanvasMouseUp);
|
||||
mapCanvas.addEventListener('mousedown', onCanvasMouseDown);
|
||||
mapCanvas.addEventListener('mousemove', onCanvasMouseMove);
|
||||
mapCanvas.addEventListener('mouseup', onCanvasMouseUp);
|
||||
mapCanvas.addEventListener('mouseleave', onCanvasMouseUp);
|
||||
}
|
||||
|
||||
function onCanvasMouseDown(e) {
|
||||
const r = mapCanvas.getBoundingClientRect();
|
||||
const scaleX = mapCanvas.width / r.width;
|
||||
const scaleY = mapCanvas.height / r.height;
|
||||
drawStartX = (e.clientX - r.left) * scaleX;
|
||||
drawStartY = (e.clientY - r.top) * scaleY;
|
||||
isDrawing = true;
|
||||
}
|
||||
|
||||
function onCanvasMouseMove(e) {
|
||||
if (!isDrawing) return;
|
||||
const r = mapCanvas.getBoundingClientRect();
|
||||
const scaleX = mapCanvas.width / r.width;
|
||||
const scaleY = mapCanvas.height / r.height;
|
||||
const curX = (e.clientX - r.left) * scaleX;
|
||||
const curY = (e.clientY - r.top) * scaleY;
|
||||
|
||||
mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height);
|
||||
if (layoutMapImage) mapCtx.drawImage(layoutMapImage, 0, 0, mapCanvas.width, mapCanvas.height);
|
||||
drawExistingRegions();
|
||||
|
||||
const w = curX - drawStartX;
|
||||
const h = curY - drawStartY;
|
||||
mapCtx.strokeStyle = '#3b82f6';
|
||||
mapCtx.lineWidth = 3;
|
||||
mapCtx.strokeRect(drawStartX, drawStartY, w, h);
|
||||
mapCtx.fillStyle = 'rgba(59, 130, 246, 0.2)';
|
||||
mapCtx.fillRect(drawStartX, drawStartY, w, h);
|
||||
|
||||
currentRect = { startX: drawStartX, startY: drawStartY, endX: curX, endY: curY };
|
||||
}
|
||||
|
||||
function onCanvasMouseUp() { isDrawing = false; }
|
||||
|
||||
function drawExistingRegions() {
|
||||
mapRegions.forEach(region => {
|
||||
const x1 = (region.x_start / 100) * mapCanvas.width;
|
||||
const y1 = (region.y_start / 100) * mapCanvas.height;
|
||||
const x2 = (region.x_end / 100) * mapCanvas.width;
|
||||
const y2 = (region.y_end / 100) * mapCanvas.height;
|
||||
mapCtx.strokeStyle = '#10b981';
|
||||
mapCtx.lineWidth = 2;
|
||||
mapCtx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
mapCtx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
||||
mapCtx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||||
mapCtx.fillStyle = '#10b981';
|
||||
mapCtx.font = '14px sans-serif';
|
||||
mapCtx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
||||
});
|
||||
}
|
||||
|
||||
function clearCurrentRegion() {
|
||||
currentRect = null;
|
||||
mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height);
|
||||
if (layoutMapImage) mapCtx.drawImage(layoutMapImage, 0, 0, mapCanvas.width, mapCanvas.height);
|
||||
drawExistingRegions();
|
||||
}
|
||||
|
||||
async function saveRegion() {
|
||||
const wpId = document.getElementById('regionWorkplaceSelect').value;
|
||||
if (!wpId) { showToast('작업장을 선택해주세요.', 'error'); return; }
|
||||
if (!currentRect) { showToast('영역을 그려주세요.', 'error'); return; }
|
||||
|
||||
try {
|
||||
const xStart = (Math.min(currentRect.startX, currentRect.endX) / mapCanvas.width * 100).toFixed(2);
|
||||
const yStart = (Math.min(currentRect.startY, currentRect.endY) / mapCanvas.height * 100).toFixed(2);
|
||||
const xEnd = (Math.max(currentRect.startX, currentRect.endX) / mapCanvas.width * 100).toFixed(2);
|
||||
const yEnd = (Math.max(currentRect.startY, currentRect.endY) / mapCanvas.height * 100).toFixed(2);
|
||||
|
||||
const existing = mapRegions.find(r => r.workplace_id == wpId);
|
||||
const body = { workplace_id: parseInt(wpId), category_id: selectedMapCategoryId, x_start: xStart, y_start: yStart, x_end: xEnd, y_end: yEnd, shape: 'rect' };
|
||||
|
||||
if (existing) {
|
||||
await api(`/workplaces/map-regions/${existing.region_id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
await api('/workplaces/map-regions', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
showToast('영역이 저장되었습니다.');
|
||||
await loadLayoutMapData();
|
||||
updateRegionWorkplaceSelect();
|
||||
clearCurrentRegion();
|
||||
document.getElementById('regionWorkplaceSelect').value = '';
|
||||
} catch(e) { showToast(e.message || '저장 실패', 'error'); }
|
||||
}
|
||||
|
||||
function renderRegionList() {
|
||||
const div = document.getElementById('regionList');
|
||||
if (!mapRegions.length) { div.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">정의된 영역이 없습니다</p>'; return; }
|
||||
div.innerHTML = '<div class="space-y-2">' + mapRegions.map(r => `
|
||||
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-gray-800">${r.workplace_name || ''}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">(${Number(r.x_start).toFixed(1)}%, ${Number(r.y_start).toFixed(1)}%) ~ (${Number(r.x_end).toFixed(1)}%, ${Number(r.y_end).toFixed(1)}%)</span>
|
||||
</div>
|
||||
<button onclick="deleteRegion(${r.region_id})" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded text-xs"><i class="fas fa-trash-alt"></i> 삭제</button>
|
||||
</div>`).join('') + '</div>';
|
||||
}
|
||||
|
||||
async function deleteRegion(regionId) {
|
||||
if (!confirm('이 영역을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api(`/workplaces/map-regions/${regionId}`, { method: 'DELETE' });
|
||||
showToast('영역이 삭제되었습니다.');
|
||||
await loadLayoutMapData();
|
||||
updateRegionWorkplaceSelect();
|
||||
} catch(e) { showToast(e.message || '삭제 실패', 'error'); }
|
||||
}
|
||||
Reference in New Issue
Block a user