feat: TBM 중복 배정 방지, 설비 배치도 좌표계 통일, 구역 상세 CSS 수정

- TBM 팀원 추가 시 중복 배정 검증 및 409 에러 처리 (tbmController, tbmModel, tbm-create.js, tbm.js, tbm/api.js)
- tkuser/tkfb 설비 배치도 좌표계를 좌상단 기준으로 통일 (CSS left/top 방식)
- tkuser 설비 배치도에 드래그 이동, 코너 리사이즈, 배치 버튼 추가
- 대분류 지도 영역 수정 버튼 추가 (workplace-layout-map.js, tkuser-layout-map.js)
- tkfb workplace-status 캔버스 maxWidth 800 통일
- zone-detail.css object-fit:contain 제거 → height:auto로 마커 위치 정확도 개선
- imageUploadService 업로드 경로 Docker 볼륨 마운트 경로로 수정
- repair-management 카테고리 필터 nonconformity → facility 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-05 15:38:11 +09:00
parent 7a12869d26
commit e18983ac06
16 changed files with 465 additions and 72 deletions

View File

@@ -300,10 +300,46 @@ function renderRegionList() {
<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 class="flex gap-1">
<button onclick="editRegion(${r.workplace_id})" class="p-1.5 text-blue-500 hover:text-blue-700 hover:bg-blue-100 rounded text-xs"><i class="fas fa-pen-to-square"></i> 수정</button>
<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>
</div>`).join('') + '</div>';
}
function editRegion(workplaceId) {
const sel = document.getElementById('regionWorkplaceSelect');
if (sel) sel.value = workplaceId;
const region = mapRegions.find(r => r.workplace_id == workplaceId);
if (region && layoutMapImage && mapCanvas && mapCtx) {
mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height);
mapCtx.drawImage(layoutMapImage, 0, 0, mapCanvas.width, mapCanvas.height);
drawExistingRegions();
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 = '#f59e0b';
mapCtx.lineWidth = 3;
mapCtx.setLineDash([6, 4]);
mapCtx.strokeRect(x1, y1, x2 - x1, y2 - y1);
mapCtx.setLineDash([]);
mapCtx.fillStyle = 'rgba(245, 158, 11, 0.25)';
mapCtx.fillRect(x1, y1, x2 - x1, y2 - y1);
mapCtx.fillStyle = '#f59e0b';
mapCtx.font = 'bold 14px sans-serif';
mapCtx.fillText('✏️ ' + (region.workplace_name || ''), x1 + 5, y1 + 20);
}
showToast(`"${region?.workplace_name || '작업장'}" 위치를 수정합니다. 새 위치를 드래그한 후 저장하세요.`);
const canvasEl = document.getElementById('regionCanvas');
if (canvasEl) canvasEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
async function deleteRegion(regionId) {
if (!confirm('이 영역을 삭제하시겠습니까?')) return;
try {

View File

@@ -278,7 +278,9 @@ function displayEquipments() {
if (typeFilter) filtered = filtered.filter(e => e.equipment_type === typeFilter);
const c = document.getElementById('equipmentList');
if (!filtered.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">설비가 없습니다.</p>'; return; }
c.innerHTML = filtered.map(e => `
c.innerHTML = filtered.map(e => {
const placed = e.map_x_percent != null && e.map_y_percent != null;
return `
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" onclick="openEqDetailModal(${e.equipment_id})">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate">
@@ -289,13 +291,16 @@ function displayEquipments() {
${e.equipment_type ? `<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-600">${e.equipment_type}</span>` : ''}
${e.manufacturer ? `<span class="text-gray-400">${e.manufacturer}</span>` : ''}
${eqStatusBadge(e.status)}
${placed ? '<span class="px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-500 text-[10px]"><i class="fas fa-map-pin"></i> 배치됨</span>' : '<span class="px-1.5 py-0.5 rounded bg-orange-50 text-orange-400 text-[10px]">미배치</span>'}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="event.stopPropagation(); startPlaceEquipment(${e.equipment_id})" class="p-1.5 ${placed ? 'text-emerald-500 hover:text-emerald-700 hover:bg-emerald-100' : 'text-orange-400 hover:text-orange-600 hover:bg-orange-100'} rounded" title="${placed ? '위치 재지정' : '배치도에 배치'}"><i class="fas fa-map-pin text-xs"></i></button>
<button onclick="event.stopPropagation(); editEquipment(${e.equipment_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
<button onclick="event.stopPropagation(); deleteEquipment(${e.equipment_id},'${(e.equipment_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button>
</div>
</div>`).join('');
</div>`;
}).join('');
}
function filterEquipments() { displayEquipments(); }
@@ -386,57 +391,268 @@ function loadEqMap() {
const imgUrl = wp.layout_image.startsWith('/') ? '/uploads/' + wp.layout_image.replace(/^\/uploads\//, '') : wp.layout_image;
const img = new Image();
img.onload = function() {
const maxW = 780; const scale = img.width > maxW ? maxW / img.width : 1;
const maxW = 800; const scale = img.width > maxW ? maxW / img.width : 1;
eqMapCanvas.width = img.width * scale; eqMapCanvas.height = img.height * scale;
eqMapCtx.drawImage(img, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
eqMapImg = img;
drawEqMapEquipments();
eqMapCanvas.onclick = onEqMapClick;
initEqMapEvents();
};
img.src = imgUrl;
}
// 좌표계: map_x_percent/map_y_percent = 좌상단 기준 (tkfb CSS left/top 과 동일)
const EQ_DEFAULT_W = 8, EQ_DEFAULT_H = 6;
function drawEqMapEquipments() {
if (!eqMapCanvas || !eqMapCtx || !eqMapImg) return;
eqMapCtx.clearRect(0, 0, eqMapCanvas.width, eqMapCanvas.height);
eqMapCtx.drawImage(eqMapImg, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
equipments.forEach(eq => {
if (eq.map_x_percent == null || eq.map_y_percent == null) return;
const x = (eq.map_x_percent / 100) * eqMapCanvas.width;
const y = (eq.map_y_percent / 100) * eqMapCanvas.height;
const w = ((eq.map_width_percent || 3) / 100) * eqMapCanvas.width;
const h = ((eq.map_height_percent || 3) / 100) * eqMapCanvas.height;
const xPct = eq._dragX != null ? eq._dragX : eq.map_x_percent;
const yPct = eq._dragY != null ? eq._dragY : eq.map_y_percent;
if (xPct == null || yPct == null) return;
const wPct = eq._dragW != null ? eq._dragW : (eq.map_width_percent || EQ_DEFAULT_W);
const hPct = eq._dragH != null ? eq._dragH : (eq.map_height_percent || EQ_DEFAULT_H);
// 좌상단 기준
const left = (xPct / 100) * eqMapCanvas.width;
const top = (yPct / 100) * eqMapCanvas.height;
const w = (wPct / 100) * eqMapCanvas.width;
const h = (hPct / 100) * eqMapCanvas.height;
const colors = { active:'#10b981', maintenance:'#f59e0b', inactive:'#94a3b8', external:'#3b82f6', repair_external:'#3b82f6', repair_needed:'#ef4444' };
const isActive = eq._dragX != null || eq._dragW != null;
const color = colors[eq.status] || '#64748b';
eqMapCtx.fillStyle = color + '33'; eqMapCtx.fillRect(x - w/2, y - h/2, w, h);
eqMapCtx.strokeStyle = color; eqMapCtx.lineWidth = 2; eqMapCtx.strokeRect(x - w/2, y - h/2, w, h);
eqMapCtx.fillStyle = color; eqMapCtx.font = 'bold 10px sans-serif'; eqMapCtx.textAlign = 'center';
eqMapCtx.fillText(eq.equipment_code || eq.equipment_name, x, y - h/2 - 3);
eqMapCtx.textAlign = 'start';
// 배경
eqMapCtx.fillStyle = (isActive ? color + '55' : color + '33');
eqMapCtx.fillRect(left, top, w, h);
// 테두리
eqMapCtx.strokeStyle = isActive ? '#f59e0b' : color;
eqMapCtx.lineWidth = isActive ? 3 : 2;
if (isActive) eqMapCtx.setLineDash([4, 3]);
eqMapCtx.strokeRect(left, top, w, h);
if (isActive) eqMapCtx.setLineDash([]);
// 라벨 (하단)
eqMapCtx.fillStyle = color; eqMapCtx.font = 'bold 10px sans-serif'; eqMapCtx.textAlign = 'start';
eqMapCtx.fillText(eq.equipment_code || eq.equipment_name, left, top + h + 12);
// 리사이즈 모서리 핸들 (4개)
const corners = [
{ cx: left, cy: top },
{ cx: left + w, cy: top },
{ cx: left, cy: top + h },
{ cx: left + w, cy: top + h },
];
corners.forEach(c => {
eqMapCtx.fillStyle = '#fff';
eqMapCtx.fillRect(c.cx - 3, c.cy - 3, 6, 6);
eqMapCtx.strokeStyle = color;
eqMapCtx.lineWidth = 1.5;
eqMapCtx.strokeRect(c.cx - 3, c.cy - 3, 6, 6);
});
});
}
let eqMapPlacingId = null;
let eqDraggingId = null, eqDragOffsetX = 0, eqDragOffsetY = 0, eqDragMoved = false;
let eqResizing = null;
function getCanvasXY(e) {
const r = eqMapCanvas.getBoundingClientRect();
const scaleX = eqMapCanvas.width / r.width;
const scaleY = eqMapCanvas.height / r.height;
return { px: (e.clientX - r.left) * scaleX, py: (e.clientY - r.top) * scaleY };
}
function getEqRect(eq) {
const left = (eq.map_x_percent / 100) * eqMapCanvas.width;
const top = (eq.map_y_percent / 100) * eqMapCanvas.height;
const w = ((eq.map_width_percent || EQ_DEFAULT_W) / 100) * eqMapCanvas.width;
const h = ((eq.map_height_percent || EQ_DEFAULT_H) / 100) * eqMapCanvas.height;
return { left, top, w, h, right: left + w, bottom: top + h };
}
function findEqAtPos(px, py) {
for (const eq of equipments) {
if (eq.map_x_percent == null || eq.map_y_percent == null) continue;
const r = getEqRect(eq);
if (px >= r.left - 5 && px <= r.right + 5 && py >= r.top - 12 && py <= r.bottom + 5) return eq;
}
return null;
}
// 모서리 근처인지 판별 (8px 범위), 어느 모서리인지 반환
function findResizeCorner(eq, px, py) {
const r = getEqRect(eq);
const margin = 8;
const corners = [
{ name: 'nw', cx: r.left, cy: r.top },
{ name: 'ne', cx: r.right, cy: r.top },
{ name: 'sw', cx: r.left, cy: r.bottom },
{ name: 'se', cx: r.right, cy: r.bottom },
];
for (const c of corners) {
if (Math.abs(px - c.cx) <= margin && Math.abs(py - c.cy) <= margin) return c.name;
}
return null;
}
function resizeCursor(corner) {
const map = { nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize' };
return map[corner] || 'default';
}
function initEqMapEvents() {
if (!eqMapCanvas) return;
eqMapCanvas.onmousedown = function(e) {
const { px, py } = getCanvasXY(e);
if (eqMapPlacingId) return;
const eq = findEqAtPos(px, py);
if (eq) {
const corner = findResizeCorner(eq, px, py);
if (corner) {
const r = getEqRect(eq);
eqResizing = { eqId: eq.equipment_id, corner, origLeft: r.left, origTop: r.top, origRight: r.right, origBottom: r.bottom };
eqMapCanvas.style.cursor = resizeCursor(corner);
e.preventDefault();
return;
}
// 이동: 좌상단 기준 오프셋 계산
const r = getEqRect(eq);
eqDraggingId = eq.equipment_id;
eqDragOffsetX = px - r.left;
eqDragOffsetY = py - r.top;
eqDragMoved = false;
eqMapCanvas.style.cursor = 'grabbing';
e.preventDefault();
}
};
eqMapCanvas.onmousemove = function(e) {
const { px, py } = getCanvasXY(e);
// 리사이즈 중
if (eqResizing) {
const eq = equipments.find(x => x.equipment_id === eqResizing.eqId);
if (!eq) return;
let { origLeft, origTop, origRight, origBottom } = eqResizing;
const corner = eqResizing.corner;
if (corner.includes('n')) origTop = py;
if (corner.includes('s')) origBottom = py;
if (corner.includes('w')) origLeft = px;
if (corner.includes('e')) origRight = px;
if (Math.abs(origRight - origLeft) < 10 || Math.abs(origBottom - origTop) < 10) return;
const newLeft = Math.min(origLeft, origRight);
const newRight = Math.max(origLeft, origRight);
const newTop = Math.min(origTop, origBottom);
const newBottom = Math.max(origTop, origBottom);
// 좌상단 기준 저장
eq._dragX = (newLeft / eqMapCanvas.width * 100);
eq._dragY = (newTop / eqMapCanvas.height * 100);
eq._dragW = ((newRight - newLeft) / eqMapCanvas.width * 100);
eq._dragH = ((newBottom - newTop) / eqMapCanvas.height * 100);
drawEqMapEquipments();
return;
}
// 드래그 이동 중 (좌상단 = 마우스위치 - 오프셋)
if (eqDraggingId) {
const eq = equipments.find(x => x.equipment_id === eqDraggingId);
if (!eq) return;
eqDragMoved = true;
const newLeft = px - eqDragOffsetX;
const newTop = py - eqDragOffsetY;
eq._dragX = (newLeft / eqMapCanvas.width * 100);
eq._dragY = (newTop / eqMapCanvas.height * 100);
drawEqMapEquipments();
return;
}
// 호버 커서
if (eqMapPlacingId) { eqMapCanvas.style.cursor = 'crosshair'; return; }
const eq = findEqAtPos(px, py);
if (eq) {
const corner = findResizeCorner(eq, px, py);
eqMapCanvas.style.cursor = corner ? resizeCursor(corner) : 'grab';
} else {
eqMapCanvas.style.cursor = 'default';
}
};
eqMapCanvas.onmouseup = function(e) {
// 리사이즈 완료
if (eqResizing) {
const eq = equipments.find(x => x.equipment_id === eqResizing.eqId);
if (eq && eq._dragX != null) {
saveEqMapFull(eq.equipment_id, eq._dragX, eq._dragY, eq._dragW, eq._dragH);
delete eq._dragX; delete eq._dragY; delete eq._dragW; delete eq._dragH;
}
eqResizing = null;
eqMapCanvas.style.cursor = 'default';
return;
}
// 드래그 이동 완료
if (eqDraggingId) {
const eq = equipments.find(x => x.equipment_id === eqDraggingId);
if (eq && eqDragMoved && eq._dragX != null) {
saveEqMapFull(eqDraggingId, eq._dragX, eq._dragY, eq.map_width_percent || EQ_DEFAULT_W, eq.map_height_percent || EQ_DEFAULT_H);
}
if (eq) { delete eq._dragX; delete eq._dragY; }
eqDraggingId = null;
eqDragMoved = false;
eqMapCanvas.style.cursor = 'default';
}
};
eqMapCanvas.onmouseleave = function() {
if (eqDraggingId || eqResizing) {
const id = eqDraggingId || eqResizing?.eqId;
const eq = equipments.find(x => x.equipment_id === id);
if (eq) { delete eq._dragX; delete eq._dragY; delete eq._dragW; delete eq._dragH; }
eqDraggingId = null; eqResizing = null; eqDragMoved = false;
drawEqMapEquipments();
}
};
eqMapCanvas.onclick = onEqMapClick;
}
function onEqMapClick(e) {
if (!eqMapPlacingId) return;
const r = eqMapCanvas.getBoundingClientRect();
const scaleX = eqMapCanvas.width / r.width; const scaleY = eqMapCanvas.height / r.height;
const px = (e.clientX - r.left) * scaleX; const py = (e.clientY - r.top) * scaleY;
const xPct = (px / eqMapCanvas.width * 100).toFixed(2); const yPct = (py / eqMapCanvas.height * 100).toFixed(2);
saveEqMapPosition(eqMapPlacingId, xPct, yPct);
const { px, py } = getCanvasXY(e);
// 클릭 위치 = 좌상단 기준, 기본 크기 8x6 (tkfb 동일)
const xPct = (px / eqMapCanvas.width * 100).toFixed(2);
const yPct = (py / eqMapCanvas.height * 100).toFixed(2);
const eq = equipments.find(x => x.equipment_id === eqMapPlacingId);
saveEqMapFull(eqMapPlacingId, parseFloat(xPct), parseFloat(yPct), eq?.map_width_percent || EQ_DEFAULT_W, eq?.map_height_percent || EQ_DEFAULT_H);
eqMapPlacingId = null; eqMapCanvas.style.cursor = 'default';
}
async function saveEqMapPosition(eqId, x, y) {
async function saveEqMapFull(eqId, x, y, w, h) {
try {
await api(`/equipments/${eqId}/map-position`, { method: 'PATCH', body: JSON.stringify({ map_x_percent: parseFloat(x), map_y_percent: parseFloat(y), map_width_percent: 3, map_height_percent: 3 }) });
await api(`/equipments/${eqId}/map-position`, { method: 'PATCH', body: JSON.stringify({
map_x_percent: parseFloat(parseFloat(x).toFixed(2)),
map_y_percent: parseFloat(parseFloat(y).toFixed(2)),
map_width_percent: parseFloat(parseFloat(w).toFixed(2)),
map_height_percent: parseFloat(parseFloat(h).toFixed(2))
}) });
showToast('설비 위치가 저장되었습니다.'); await loadEquipments();
} catch(e) { showToast(e.message, 'error'); }
}
async function saveEqMapPosition(eqId, x, y) {
const eq = equipments.find(e => e.equipment_id === eqId);
saveEqMapFull(eqId, x, y, eq?.map_width_percent || EQ_DEFAULT_W, eq?.map_height_percent || EQ_DEFAULT_H);
}
function startPlaceEquipment(eqId) {
eqMapPlacingId = eqId; if(eqMapCanvas) eqMapCanvas.style.cursor = 'crosshair';
showToast('배치도에서 위치를 클릭하세요');
eqMapPlacingId = eqId;
if (eqMapCanvas) eqMapCanvas.style.cursor = 'crosshair';
const eq = equipments.find(x => x.equipment_id === eqId);
const placed = eq?.map_x_percent != null;
showToast(`"${eq?.equipment_name || '설비'}" ${placed ? '위치 재지정' : '배치'} - 배치도에서 위치를 클릭하세요`);
document.getElementById('eqMapCanvasWrap')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
async function uploadWorkplaceLayoutImage() {