From e18983ac06a2ed6dbd0b4536df0f4c03e7edd720 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 5 Mar 2026 15:38:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20TBM=20=EC=A4=91=EB=B3=B5=20=EB=B0=B0?= =?UTF-8?q?=EC=A0=95=20=EB=B0=A9=EC=A7=80,=20=EC=84=A4=EB=B9=84=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=EB=8F=84=20=EC=A2=8C=ED=91=9C=EA=B3=84=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC,=20=EA=B5=AC=EC=97=AD=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20CSS=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/controllers/tbmController.js | 26 ++ system1-factory/api/models/tbmModel.js | 21 ++ .../api/services/imageUploadService.js | 6 +- system1-factory/web/css/zone-detail.css | 9 +- system1-factory/web/js/tbm-create.js | 46 ++- system1-factory/web/js/tbm.js | 39 ++- system1-factory/web/js/tbm/api.js | 4 +- .../web/js/workplace-layout-map.js | 60 +++- system1-factory/web/js/workplace-status.js | 10 +- .../web/pages/admin/repair-management.html | 2 +- .../web/pages/admin/workplaces.html | 2 +- system1-factory/web/pages/dashboard.html | 2 +- .../web/pages/inspection/zone-detail.html | 2 +- user-management/web/index.html | 8 +- .../web/static/js/tkuser-layout-map.js | 38 ++- .../web/static/js/tkuser-workplaces.js | 262 ++++++++++++++++-- 16 files changed, 465 insertions(+), 72 deletions(-) diff --git a/system1-factory/api/controllers/tbmController.js b/system1-factory/api/controllers/tbmController.js index cb44eff..484f6b9 100644 --- a/system1-factory/api/controllers/tbmController.js +++ b/system1-factory/api/controllers/tbmController.js @@ -194,6 +194,32 @@ const TbmController = { return res.status(400).json({ success: false, message: '팀원 목록이 필요합니다.' }); } + // 중복 배정 검증 + const sessionRows = await TbmModel.getSessionById(sessionId); + if (sessionRows.length > 0) { + const sessionDate = sessionRows[0].session_date; + let dateStr; + if (sessionDate instanceof Date) { + dateStr = sessionDate.toISOString().split('T')[0]; + } else if (typeof sessionDate === 'string') { + dateStr = sessionDate.split('T')[0]; + } else { + dateStr = new Date(sessionDate).toISOString().split('T')[0]; + } + + const userIds = members.map(m => m.user_id); + const duplicates = await TbmModel.checkDuplicateAssignments(dateStr, userIds, sessionId); + + if (duplicates.length > 0) { + const names = duplicates.map(d => d.worker_name).join(', '); + return res.status(409).json({ + success: false, + message: `다음 작업자가 이미 다른 TBM에 배정되어 있습니다: ${names}`, + duplicates: duplicates + }); + } + } + await TbmModel.addTeamMembers(sessionId, members); res.json({ success: true, diff --git a/system1-factory/api/models/tbmModel.js b/system1-factory/api/models/tbmModel.js index ad35da2..57704b0 100644 --- a/system1-factory/api/models/tbmModel.js +++ b/system1-factory/api/models/tbmModel.js @@ -848,6 +848,27 @@ const TbmModel = { [checkId] ); return { affectedRows: result.affectedRows }; + }, + + // ==================== 중복 배정 검증 ==================== + + checkDuplicateAssignments: async (date, userIds, excludeSessionId) => { + if (!userIds || userIds.length === 0) return []; + const db = await getDb(); + const placeholders = userIds.map(() => '?').join(','); + const [rows] = await db.query( + `SELECT ta.user_id, w.worker_name, lw.worker_name AS leader_name + FROM tbm_team_assignments ta + INNER JOIN tbm_sessions s ON ta.session_id = s.session_id + INNER JOIN workers w ON ta.user_id = w.user_id + LEFT JOIN workers lw ON s.leader_user_id = lw.user_id + WHERE s.session_date = ? + AND ta.session_id != ? + AND ta.user_id IN (${placeholders}) + GROUP BY ta.user_id`, + [date, excludeSessionId, ...userIds] + ); + return rows; } }; diff --git a/system1-factory/api/services/imageUploadService.js b/system1-factory/api/services/imageUploadService.js index 7870130..e898f78 100644 --- a/system1-factory/api/services/imageUploadService.js +++ b/system1-factory/api/services/imageUploadService.js @@ -19,10 +19,10 @@ try { console.warn('이미지 최적화를 위해 npm install sharp 를 실행하세요.'); } -// 업로드 디렉토리 설정 +// 업로드 디렉토리 설정 (Docker 볼륨 마운트: /usr/src/app/uploads) const UPLOAD_DIRS = { - issues: path.join(__dirname, '../public/uploads/issues'), - equipments: path.join(__dirname, '../public/uploads/equipments') + issues: path.join(__dirname, '../uploads/issues'), + equipments: path.join(__dirname, '../uploads/equipments') }; const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지 const MAX_SIZE = { width: 1920, height: 1920 }; diff --git a/system1-factory/web/css/zone-detail.css b/system1-factory/web/css/zone-detail.css index cb31729..17777ef 100644 --- a/system1-factory/web/css/zone-detail.css +++ b/system1-factory/web/css/zone-detail.css @@ -784,8 +784,9 @@ background: var(--bg-color, #f8fafc); border: 2px solid var(--border-color, #e2e8f0); border-radius: 12px; - min-height: 400px; + min-height: 200px; overflow: hidden; + align-self: flex-start; } .zone-map-container.adding-item { @@ -796,8 +797,8 @@ .zone-map-image { width: 100%; - height: 100%; - object-fit: contain; + height: auto; + display: block; } .map-placeholder { @@ -1369,7 +1370,7 @@ } .zone-map-container { - min-height: 300px; + min-height: 150px; } .zone-item-marker { diff --git a/system1-factory/web/js/tbm-create.js b/system1-factory/web/js/tbm-create.js index 8f5dbaf..490d87f 100644 --- a/system1-factory/web/js/tbm-create.js +++ b/system1-factory/web/js/tbm-create.js @@ -218,22 +218,18 @@ var workerCards = workers.map(function(w) { var selected = W.workers.has(w.user_id) ? ' selected' : ''; var assignment = W.todayAssignments[w.user_id]; - var assigned = assignment && assignment.total_hours >= 8; - var partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8; + var assigned = assignment && assignment.sessions && assignment.sessions.length > 0; var badgeHtml = ''; var disabledClass = ''; var onclick = 'toggleWorker(' + w.user_id + ')'; if (assigned) { - // 종일 배정됨 - 선택 불가 + // 이미 배정됨 - 선택 불가 var leaderNames = assignment.sessions.map(function(s) { return s.leader_name || ''; }).join(', '); - badgeHtml = '
' + esc(leaderNames) + ' TBM (' + assignment.total_hours + 'h)
'; + badgeHtml = '
배정됨 - ' + esc(leaderNames) + ' TBM
'; disabledClass = ' disabled'; onclick = ''; - } else if (partiallyAssigned) { - var remaining = 8 - assignment.total_hours; - badgeHtml = '
' + remaining + 'h 가용
'; } return '
= 8) return; + if (a && a.sessions && a.sessions.length > 0) return; if (W.workers.has(workerId)) { W.workers.delete(workerId); @@ -285,7 +281,7 @@ var workers = window.TbmState.allWorkers; var availableWorkers = workers.filter(function(w) { var a = W.todayAssignments && W.todayAssignments[w.user_id]; - return !(a && a.total_hours >= 8); + return !(a && a.sessions && a.sessions.length > 0); }); if (W.workers.size === availableWorkers.length) { W.workers.clear(); @@ -554,7 +550,10 @@ ); if (!teamResponse || !teamResponse.success) { - throw new Error(teamResponse?.message || '팀원 추가 실패'); + var err = new Error(teamResponse?.message || '팀원 추가 실패'); + if (teamResponse && teamResponse.duplicates) err.duplicates = teamResponse.duplicates; + err._sessionId = sessionId; + throw err; } showToast('TBM이 생성되었습니다 (작업자 ' + members.length + '명)', 'success'); @@ -565,7 +564,30 @@ }, 1000); } catch (error) { console.error('TBM 저장 오류:', error); - showToast('TBM 저장 중 오류가 발생했습니다: ' + error.message, 'error'); + + // 409 중복 배정 에러 처리 + if (error.duplicates && error.duplicates.length > 0) { + // 고아 세션 삭제 + if (error._sessionId) { + try { await window.apiCall('/tbm/sessions/' + error._sessionId, 'DELETE'); } catch(e) {} + } + // 중복 작업자 자동 해제 + error.duplicates.forEach(function(d) { + W.workers.delete(d.user_id); + delete W.workerNames[d.user_id]; + }); + // 배정 현황 캐시 갱신 + W.todayAssignments = null; + // Step 1로 복귀 + W.step = 1; + renderStep(1); + updateIndicator(); + updateNav(); + showToast(error.message, 'error'); + } else { + showToast('TBM 저장 중 오류가 발생했습니다: ' + error.message, 'error'); + } + if (overlay) overlay.style.display = 'none'; if (saveBtn) { saveBtn.disabled = false; diff --git a/system1-factory/web/js/tbm.js b/system1-factory/web/js/tbm.js index 53f1697..ec779c2 100644 --- a/system1-factory/web/js/tbm.js +++ b/system1-factory/web/js/tbm.js @@ -456,21 +456,17 @@ async function renderNewTbmWorkerGrid() { grid.innerHTML = allWorkers.map(w => { const checked = selectedWorkersForNewTbm.has(w.user_id) ? 'checked' : ''; const assignment = todayAssignmentsMap[w.user_id]; - const fullyAssigned = assignment && assignment.total_hours >= 8; - const partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8; + const assigned = assignment && assignment.sessions && assignment.sessions.length > 0; let badgeHtml = ''; let disabledAttr = ''; let disabledStyle = ''; - if (fullyAssigned) { + if (assigned) { const leaderNames = assignment.sessions.map(s => s.leader_name || '').join(', '); - badgeHtml = `${escapeHtml(leaderNames)} TBM (${assignment.total_hours}h)`; + badgeHtml = `배정됨 - ${escapeHtml(leaderNames)} TBM`; disabledAttr = 'disabled'; disabledStyle = 'opacity:0.5; pointer-events:none;'; - } else if (partiallyAssigned) { - const remaining = 8 - assignment.total_hours; - badgeHtml = `${remaining}h 가용`; } return ` @@ -493,9 +489,9 @@ function updateNewTbmWorkerCount() { } function toggleNewTbmWorker(workerId, checked) { - // 종일 배정된 작업자 선택 방지 + // 이미 배정된 작업자 선택 방지 const a = todayAssignmentsMap && todayAssignmentsMap[workerId]; - if (a && a.total_hours >= 8) return; + if (a && a.sessions && a.sessions.length > 0) return; if (checked) { selectedWorkersForNewTbm.add(workerId); @@ -512,7 +508,7 @@ window.toggleNewTbmWorker = toggleNewTbmWorker; function selectAllNewTbmWorkers() { allWorkers.forEach(w => { const a = todayAssignmentsMap && todayAssignmentsMap[w.user_id]; - if (a && a.total_hours >= 8) return; // 종일 배정 제외 + if (a && a.sessions && a.sessions.length > 0) return; // 배정됨 제외 selectedWorkersForNewTbm.add(w.user_id); }); document.querySelectorAll('.new-tbm-worker-cb').forEach(cb => { @@ -743,11 +739,12 @@ async function saveTbmSession() { }); }); + let createdSessionId = null; try { const response = await window.TbmAPI.createTbmSession(sessionData); if (response && response.success) { - const createdSessionId = response.data.session_id; + createdSessionId = response.data.session_id; console.log('✅ TBM 세션 생성 완료:', createdSessionId); const teamResponse = await window.TbmAPI.addTeamMembers(createdSessionId, members); @@ -769,7 +766,25 @@ async function saveTbmSession() { } } catch (error) { console.error('❌ TBM 세션 저장 오류:', error); - showToast('TBM 세션 저장 중 오류가 발생했습니다.', 'error'); + + // 409 중복 배정 에러 처리 + if (error.duplicates && error.duplicates.length > 0) { + // 고아 세션 삭제 + if (createdSessionId) { + try { await window.TbmAPI.deleteSession(createdSessionId); } catch(e) {} + } + // 중복 작업자 자동 해제 + const dupIds = new Set(error.duplicates.map(d => d.user_id)); + dupIds.forEach(uid => { + selectedWorkersForNewTbm.delete(uid); + }); + // 배정 현황 캐시 갱신 후 그리드 새로고침 + todayAssignmentsMap = null; + await renderNewTbmWorkerGrid(); + showToast(error.message, 'error'); + } else { + showToast('TBM 세션 저장 중 오류가 발생했습니다.', 'error'); + } } } window.saveTbmSession = saveTbmSession; diff --git a/system1-factory/web/js/tbm/api.js b/system1-factory/web/js/tbm/api.js index e730f45..6006eae 100644 --- a/system1-factory/web/js/tbm/api.js +++ b/system1-factory/web/js/tbm/api.js @@ -309,7 +309,9 @@ class TbmAPI { { members } ); if (!response || !response.success) { - throw new Error(response?.message || '팀원 추가 실패'); + const err = new Error(response?.message || '팀원 추가 실패'); + if (response && response.duplicates) err.duplicates = response.duplicates; + throw err; } console.log('✅ TBM 팀원 추가 완료:', members.length + '명'); return response; diff --git a/system1-factory/web/js/workplace-layout-map.js b/system1-factory/web/js/workplace-layout-map.js index c7c8252..949d9a1 100644 --- a/system1-factory/web/js/workplace-layout-map.js +++ b/system1-factory/web/js/workplace-layout-map.js @@ -447,12 +447,17 @@ function renderRegionList() {
${region.workplace_name} - (${region.x_start}%, ${region.y_start}%) ~ (${region.x_end}%, ${region.y_end}%) + (${parseFloat(region.x_start).toFixed(1)}%, ${parseFloat(region.y_start).toFixed(1)}%) ~ (${parseFloat(region.x_end).toFixed(1)}%, ${parseFloat(region.y_end).toFixed(1)}%)
- +
+ + +
`; }); @@ -461,6 +466,52 @@ function renderRegionList() { listDiv.innerHTML = listHtml; } +/** + * 영역 수정 모드 진입 + */ +function editRegion(workplaceId) { + // 작업장 드롭다운 선택 + const select = document.getElementById('regionWorkplaceSelect'); + if (select) { + select.value = workplaceId; + } + + // 해당 영역 하이라이트 + const region = mapRegions.find(r => r.workplace_id == workplaceId); + if (region && layoutMapImage) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height); + drawExistingRegions(); + + // 수정 대상 영역을 주황색으로 강조 + 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; + + ctx.strokeStyle = '#f59e0b'; + ctx.lineWidth = 3; + ctx.setLineDash([6, 4]); + ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); + ctx.setLineDash([]); + + ctx.fillStyle = 'rgba(245, 158, 11, 0.25)'; + ctx.fillRect(x1, y1, x2 - x1, y2 - y1); + + // 라벨 + ctx.fillStyle = '#f59e0b'; + ctx.font = 'bold 14px sans-serif'; + ctx.fillText('✏️ ' + (region.workplace_name || ''), x1 + 5, y1 + 20); + } + + window.showToast(`"${region?.workplace_name || '작업장'}" 위치를 수정합니다. 지도에서 새 위치를 드래그한 후 저장하세요.`, 'info'); + + // 캔버스 영역으로 스크롤 + if (canvas) { + canvas.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + /** * 영역 삭제 */ @@ -492,3 +543,4 @@ window.uploadLayoutImage = uploadLayoutImage; window.clearCurrentRegion = clearCurrentRegion; window.saveRegion = saveRegion; window.deleteRegion = deleteRegion; +window.editRegion = editRegion; diff --git a/system1-factory/web/js/workplace-status.js b/system1-factory/web/js/workplace-status.js index 5cb9d1c..3d468ce 100644 --- a/system1-factory/web/js/workplace-status.js +++ b/system1-factory/web/js/workplace-status.js @@ -137,10 +137,12 @@ async function loadMapImage() { img.onload = () => { canvasImage = img; - // 캔버스 초기화 + // 캔버스 초기화 (maxWidth 800으로 통일) canvas = document.getElementById('workplaceMapCanvas'); - canvas.width = img.width; - canvas.height = img.height; + const maxW = 800; + const scale = img.width > maxW ? maxW / img.width : 1; + canvas.width = img.width * scale; + canvas.height = img.height * scale; ctx = canvas.getContext('2d'); // 클릭 이벤트 @@ -254,7 +256,7 @@ function renderMap() { // 이미지 그리기 ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(canvasImage, 0, 0); + ctx.drawImage(canvasImage, 0, 0, canvas.width, canvas.height); // 모든 작업장 영역 표시 mapRegions.forEach(region => { diff --git a/system1-factory/web/pages/admin/repair-management.html b/system1-factory/web/pages/admin/repair-management.html index ecb9a6a..87db8a5 100644 --- a/system1-factory/web/pages/admin/repair-management.html +++ b/system1-factory/web/pages/admin/repair-management.html @@ -504,7 +504,7 @@ async function loadRepairRequests() { try { - const response = await window.apiCall('/work-issues?category_type=nonconformity'); + const response = await window.apiCall('/work-issues?category_type=facility'); if (response.success) { allRepairs = response.data || []; updateStats(); diff --git a/system1-factory/web/pages/admin/workplaces.html b/system1-factory/web/pages/admin/workplaces.html index c6de001..c207938 100644 --- a/system1-factory/web/pages/admin/workplaces.html +++ b/system1-factory/web/pages/admin/workplaces.html @@ -424,6 +424,6 @@ - + diff --git a/system1-factory/web/pages/dashboard.html b/system1-factory/web/pages/dashboard.html index 3a0ae5c..cd00541 100644 --- a/system1-factory/web/pages/dashboard.html +++ b/system1-factory/web/pages/dashboard.html @@ -23,7 +23,7 @@ - + diff --git a/system1-factory/web/pages/inspection/zone-detail.html b/system1-factory/web/pages/inspection/zone-detail.html index 428fe58..1525b62 100644 --- a/system1-factory/web/pages/inspection/zone-detail.html +++ b/system1-factory/web/pages/inspection/zone-detail.html @@ -6,7 +6,7 @@ 구역 상세 | (주)테크니컬코리아 - + diff --git a/user-management/web/index.html b/user-management/web/index.html index 1fc7700..6142c32 100644 --- a/user-management/web/index.html +++ b/user-management/web/index.html @@ -792,7 +792,7 @@ @@ -1249,7 +1249,7 @@

영역 그리기 (캔버스 위에서 드래그하여 사각형 영역을 그리세요)

- +
@@ -1396,10 +1396,10 @@ - + - + diff --git a/user-management/web/static/js/tkuser-layout-map.js b/user-management/web/static/js/tkuser-layout-map.js index 0a2d9e1..4fccb13 100644 --- a/user-management/web/static/js/tkuser-layout-map.js +++ b/user-management/web/static/js/tkuser-layout-map.js @@ -300,10 +300,46 @@ function renderRegionList() { ${r.workplace_name || ''} (${Number(r.x_start).toFixed(1)}%, ${Number(r.y_start).toFixed(1)}%) ~ (${Number(r.x_end).toFixed(1)}%, ${Number(r.y_end).toFixed(1)}%) - +
+ + +
`).join('') + ''; } +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 { diff --git a/user-management/web/static/js/tkuser-workplaces.js b/user-management/web/static/js/tkuser-workplaces.js index c898375..f9064ac 100644 --- a/user-management/web/static/js/tkuser-workplaces.js +++ b/user-management/web/static/js/tkuser-workplaces.js @@ -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 = '

설비가 없습니다.

'; return; } - c.innerHTML = filtered.map(e => ` + c.innerHTML = filtered.map(e => { + const placed = e.map_x_percent != null && e.map_y_percent != null; + return `
@@ -289,13 +291,16 @@ function displayEquipments() { ${e.equipment_type ? `${e.equipment_type}` : ''} ${e.manufacturer ? `${e.manufacturer}` : ''} ${eqStatusBadge(e.status)} + ${placed ? ' 배치됨' : '미배치'}
+
-
`).join(''); + `; + }).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() {