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

@@ -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 {

View File

@@ -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 = '<div style="font-size:0.625rem; color:#ef4444; margin-top:0.125rem;">' + esc(leaderNames) + ' TBM (' + assignment.total_hours + 'h)</div>';
badgeHtml = '<div style="font-size:0.625rem; color:#ef4444; margin-top:0.125rem;">배정됨 - ' + esc(leaderNames) + ' TBM</div>';
disabledClass = ' disabled';
onclick = '';
} else if (partiallyAssigned) {
var remaining = 8 - assignment.total_hours;
badgeHtml = '<div style="font-size:0.625rem; color:#2563eb; margin-top:0.125rem;">' + remaining + 'h 가용</div>';
}
return '<div class="worker-card' + selected + disabledClass + '"' +
@@ -263,9 +259,9 @@
}
window.toggleWorker = function(workerId) {
// 이미 종일 배정된 작업자는 선택 불가
// 이미 배정된 작업자는 선택 불가
var a = W.todayAssignments && W.todayAssignments[workerId];
if (a && a.total_hours >= 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;

View File

@@ -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 = `<span style="font-size:0.625rem; color:#ef4444; display:block;">${escapeHtml(leaderNames)} TBM (${assignment.total_hours}h)</span>`;
badgeHtml = `<span style="font-size:0.625rem; color:#ef4444; display:block;">배정됨 - ${escapeHtml(leaderNames)} TBM</span>`;
disabledAttr = 'disabled';
disabledStyle = 'opacity:0.5; pointer-events:none;';
} else if (partiallyAssigned) {
const remaining = 8 - assignment.total_hours;
badgeHtml = `<span style="font-size:0.625rem; color:#2563eb; display:block;">${remaining}h 가용</span>`;
}
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;

View File

@@ -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;

View File

@@ -447,12 +447,17 @@ function renderRegionList() {
<div>
<span style="font-weight: 600; color: #1e293b;">${region.workplace_name}</span>
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">
(${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)}%)
</span>
</div>
<button onclick="deleteRegion(${region.region_id})" class="btn-small btn-delete" style="padding: 4px 8px; font-size: 12px;">
🗑️ 삭제
</button>
<div style="display: flex; gap: 4px;">
<button onclick="editRegion(${region.workplace_id})" class="btn-small" style="padding: 4px 8px; font-size: 12px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer;">
✏️ 수정
</button>
<button onclick="deleteRegion(${region.region_id})" class="btn-small btn-delete" style="padding: 4px 8px; font-size: 12px; border: none; border-radius: 4px; cursor: pointer;">
🗑️ 삭제
</button>
</div>
</div>
`;
});
@@ -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;

View File

@@ -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 => {

View File

@@ -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();

View File

@@ -424,6 +424,6 @@
<script src="/js/workplace-management/index.js?v=1"></script>
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/workplace-management.js?v=9"></script>
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
<script type="module" src="/js/workplace-layout-map.js?v=2"></script>
</body>
</html>

View File

@@ -23,7 +23,7 @@
<script src="/js/app-init.js?v=9" defer></script>
<script type="module" src="/js/modern-dashboard.js?v=10" defer></script>
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
<script src="/js/workplace-status.js?v=2" defer></script>
<script src="/js/workplace-status.js?v=3" defer></script>
<script src="/js/mobile-dashboard.js?v=4" defer></script>
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
<script src="https://instant.page/5.2.0" type="module"></script>

View File

@@ -6,7 +6,7 @@
<title>구역 상세 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="stylesheet" href="/css/zone-detail.css?v=3">
<link rel="stylesheet" href="/css/zone-detail.css?v=4">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js?v=3"></script>
<script src="/js/app-init.js?v=9" defer></script>