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