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:
@@ -194,6 +194,32 @@ const TbmController = {
|
|||||||
return res.status(400).json({ success: false, message: '팀원 목록이 필요합니다.' });
|
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);
|
await TbmModel.addTeamMembers(sessionId, members);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -848,6 +848,27 @@ const TbmModel = {
|
|||||||
[checkId]
|
[checkId]
|
||||||
);
|
);
|
||||||
return { affectedRows: result.affectedRows };
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ try {
|
|||||||
console.warn('이미지 최적화를 위해 npm install sharp 를 실행하세요.');
|
console.warn('이미지 최적화를 위해 npm install sharp 를 실행하세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업로드 디렉토리 설정
|
// 업로드 디렉토리 설정 (Docker 볼륨 마운트: /usr/src/app/uploads)
|
||||||
const UPLOAD_DIRS = {
|
const UPLOAD_DIRS = {
|
||||||
issues: path.join(__dirname, '../public/uploads/issues'),
|
issues: path.join(__dirname, '../uploads/issues'),
|
||||||
equipments: path.join(__dirname, '../public/uploads/equipments')
|
equipments: path.join(__dirname, '../uploads/equipments')
|
||||||
};
|
};
|
||||||
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
|
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
|
||||||
const MAX_SIZE = { width: 1920, height: 1920 };
|
const MAX_SIZE = { width: 1920, height: 1920 };
|
||||||
|
|||||||
@@ -784,8 +784,9 @@
|
|||||||
background: var(--bg-color, #f8fafc);
|
background: var(--bg-color, #f8fafc);
|
||||||
border: 2px solid var(--border-color, #e2e8f0);
|
border: 2px solid var(--border-color, #e2e8f0);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
min-height: 400px;
|
min-height: 200px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone-map-container.adding-item {
|
.zone-map-container.adding-item {
|
||||||
@@ -796,8 +797,8 @@
|
|||||||
|
|
||||||
.zone-map-image {
|
.zone-map-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
object-fit: contain;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-placeholder {
|
.map-placeholder {
|
||||||
@@ -1369,7 +1370,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.zone-map-container {
|
.zone-map-container {
|
||||||
min-height: 300px;
|
min-height: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone-item-marker {
|
.zone-item-marker {
|
||||||
|
|||||||
@@ -218,22 +218,18 @@
|
|||||||
var workerCards = workers.map(function(w) {
|
var workerCards = workers.map(function(w) {
|
||||||
var selected = W.workers.has(w.user_id) ? ' selected' : '';
|
var selected = W.workers.has(w.user_id) ? ' selected' : '';
|
||||||
var assignment = W.todayAssignments[w.user_id];
|
var assignment = W.todayAssignments[w.user_id];
|
||||||
var assigned = assignment && assignment.total_hours >= 8;
|
var assigned = assignment && assignment.sessions && assignment.sessions.length > 0;
|
||||||
var partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8;
|
|
||||||
|
|
||||||
var badgeHtml = '';
|
var badgeHtml = '';
|
||||||
var disabledClass = '';
|
var disabledClass = '';
|
||||||
var onclick = 'toggleWorker(' + w.user_id + ')';
|
var onclick = 'toggleWorker(' + w.user_id + ')';
|
||||||
|
|
||||||
if (assigned) {
|
if (assigned) {
|
||||||
// 종일 배정됨 - 선택 불가
|
// 이미 배정됨 - 선택 불가
|
||||||
var leaderNames = assignment.sessions.map(function(s) { return s.leader_name || ''; }).join(', ');
|
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';
|
disabledClass = ' disabled';
|
||||||
onclick = '';
|
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 + '"' +
|
return '<div class="worker-card' + selected + disabledClass + '"' +
|
||||||
@@ -263,9 +259,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.toggleWorker = function(workerId) {
|
window.toggleWorker = function(workerId) {
|
||||||
// 이미 종일 배정된 작업자는 선택 불가
|
// 이미 배정된 작업자는 선택 불가
|
||||||
var a = W.todayAssignments && W.todayAssignments[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)) {
|
if (W.workers.has(workerId)) {
|
||||||
W.workers.delete(workerId);
|
W.workers.delete(workerId);
|
||||||
@@ -285,7 +281,7 @@
|
|||||||
var workers = window.TbmState.allWorkers;
|
var workers = window.TbmState.allWorkers;
|
||||||
var availableWorkers = workers.filter(function(w) {
|
var availableWorkers = workers.filter(function(w) {
|
||||||
var a = W.todayAssignments && W.todayAssignments[w.user_id];
|
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) {
|
if (W.workers.size === availableWorkers.length) {
|
||||||
W.workers.clear();
|
W.workers.clear();
|
||||||
@@ -554,7 +550,10 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!teamResponse || !teamResponse.success) {
|
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');
|
showToast('TBM이 생성되었습니다 (작업자 ' + members.length + '명)', 'success');
|
||||||
@@ -565,7 +564,30 @@
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('TBM 저장 오류:', 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 (overlay) overlay.style.display = 'none';
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
|
|||||||
@@ -456,21 +456,17 @@ async function renderNewTbmWorkerGrid() {
|
|||||||
grid.innerHTML = allWorkers.map(w => {
|
grid.innerHTML = allWorkers.map(w => {
|
||||||
const checked = selectedWorkersForNewTbm.has(w.user_id) ? 'checked' : '';
|
const checked = selectedWorkersForNewTbm.has(w.user_id) ? 'checked' : '';
|
||||||
const assignment = todayAssignmentsMap[w.user_id];
|
const assignment = todayAssignmentsMap[w.user_id];
|
||||||
const fullyAssigned = assignment && assignment.total_hours >= 8;
|
const assigned = assignment && assignment.sessions && assignment.sessions.length > 0;
|
||||||
const partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8;
|
|
||||||
|
|
||||||
let badgeHtml = '';
|
let badgeHtml = '';
|
||||||
let disabledAttr = '';
|
let disabledAttr = '';
|
||||||
let disabledStyle = '';
|
let disabledStyle = '';
|
||||||
|
|
||||||
if (fullyAssigned) {
|
if (assigned) {
|
||||||
const leaderNames = assignment.sessions.map(s => s.leader_name || '').join(', ');
|
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';
|
disabledAttr = 'disabled';
|
||||||
disabledStyle = 'opacity:0.5; pointer-events:none;';
|
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 `
|
return `
|
||||||
@@ -493,9 +489,9 @@ function updateNewTbmWorkerCount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleNewTbmWorker(workerId, checked) {
|
function toggleNewTbmWorker(workerId, checked) {
|
||||||
// 종일 배정된 작업자 선택 방지
|
// 이미 배정된 작업자 선택 방지
|
||||||
const a = todayAssignmentsMap && todayAssignmentsMap[workerId];
|
const a = todayAssignmentsMap && todayAssignmentsMap[workerId];
|
||||||
if (a && a.total_hours >= 8) return;
|
if (a && a.sessions && a.sessions.length > 0) return;
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
selectedWorkersForNewTbm.add(workerId);
|
selectedWorkersForNewTbm.add(workerId);
|
||||||
@@ -512,7 +508,7 @@ window.toggleNewTbmWorker = toggleNewTbmWorker;
|
|||||||
function selectAllNewTbmWorkers() {
|
function selectAllNewTbmWorkers() {
|
||||||
allWorkers.forEach(w => {
|
allWorkers.forEach(w => {
|
||||||
const a = todayAssignmentsMap && todayAssignmentsMap[w.user_id];
|
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);
|
selectedWorkersForNewTbm.add(w.user_id);
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.new-tbm-worker-cb').forEach(cb => {
|
document.querySelectorAll('.new-tbm-worker-cb').forEach(cb => {
|
||||||
@@ -743,11 +739,12 @@ async function saveTbmSession() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let createdSessionId = null;
|
||||||
try {
|
try {
|
||||||
const response = await window.TbmAPI.createTbmSession(sessionData);
|
const response = await window.TbmAPI.createTbmSession(sessionData);
|
||||||
|
|
||||||
if (response && response.success) {
|
if (response && response.success) {
|
||||||
const createdSessionId = response.data.session_id;
|
createdSessionId = response.data.session_id;
|
||||||
console.log('✅ TBM 세션 생성 완료:', createdSessionId);
|
console.log('✅ TBM 세션 생성 완료:', createdSessionId);
|
||||||
|
|
||||||
const teamResponse = await window.TbmAPI.addTeamMembers(createdSessionId, members);
|
const teamResponse = await window.TbmAPI.addTeamMembers(createdSessionId, members);
|
||||||
@@ -769,7 +766,25 @@ async function saveTbmSession() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ TBM 세션 저장 오류:', 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;
|
window.saveTbmSession = saveTbmSession;
|
||||||
|
|||||||
@@ -309,7 +309,9 @@ class TbmAPI {
|
|||||||
{ members }
|
{ members }
|
||||||
);
|
);
|
||||||
if (!response || !response.success) {
|
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 + '명');
|
console.log('✅ TBM 팀원 추가 완료:', members.length + '명');
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -447,12 +447,17 @@ function renderRegionList() {
|
|||||||
<div>
|
<div>
|
||||||
<span style="font-weight: 600; color: #1e293b;">${region.workplace_name}</span>
|
<span style="font-weight: 600; color: #1e293b;">${region.workplace_name}</span>
|
||||||
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="deleteRegion(${region.region_id})" class="btn-small btn-delete" style="padding: 4px 8px; font-size: 12px;">
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
@@ -461,6 +466,52 @@ function renderRegionList() {
|
|||||||
listDiv.innerHTML = listHtml;
|
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.clearCurrentRegion = clearCurrentRegion;
|
||||||
window.saveRegion = saveRegion;
|
window.saveRegion = saveRegion;
|
||||||
window.deleteRegion = deleteRegion;
|
window.deleteRegion = deleteRegion;
|
||||||
|
window.editRegion = editRegion;
|
||||||
|
|||||||
@@ -137,10 +137,12 @@ async function loadMapImage() {
|
|||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
canvasImage = img;
|
canvasImage = img;
|
||||||
|
|
||||||
// 캔버스 초기화
|
// 캔버스 초기화 (maxWidth 800으로 통일)
|
||||||
canvas = document.getElementById('workplaceMapCanvas');
|
canvas = document.getElementById('workplaceMapCanvas');
|
||||||
canvas.width = img.width;
|
const maxW = 800;
|
||||||
canvas.height = img.height;
|
const scale = img.width > maxW ? maxW / img.width : 1;
|
||||||
|
canvas.width = img.width * scale;
|
||||||
|
canvas.height = img.height * scale;
|
||||||
ctx = canvas.getContext('2d');
|
ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
// 클릭 이벤트
|
// 클릭 이벤트
|
||||||
@@ -254,7 +256,7 @@ function renderMap() {
|
|||||||
|
|
||||||
// 이미지 그리기
|
// 이미지 그리기
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
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 => {
|
mapRegions.forEach(region => {
|
||||||
|
|||||||
@@ -504,7 +504,7 @@
|
|||||||
|
|
||||||
async function loadRepairRequests() {
|
async function loadRepairRequests() {
|
||||||
try {
|
try {
|
||||||
const response = await window.apiCall('/work-issues?category_type=nonconformity');
|
const response = await window.apiCall('/work-issues?category_type=facility');
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
allRepairs = response.data || [];
|
allRepairs = response.data || [];
|
||||||
updateStats();
|
updateStats();
|
||||||
|
|||||||
@@ -424,6 +424,6 @@
|
|||||||
<script src="/js/workplace-management/index.js?v=1"></script>
|
<script src="/js/workplace-management/index.js?v=1"></script>
|
||||||
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
||||||
<script type="module" src="/js/workplace-management.js?v=9"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<script src="/js/app-init.js?v=9" defer></script>
|
<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/modern-dashboard.js?v=10" defer></script>
|
||||||
<script type="module" src="/js/group-leader-dashboard.js?v=1" 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>
|
<script src="/js/mobile-dashboard.js?v=4" defer></script>
|
||||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>구역 상세 | (주)테크니컬코리아</title>
|
<title>구역 상세 | (주)테크니컬코리아</title>
|
||||||
<link rel="stylesheet" href="/css/design-system.css">
|
<link rel="stylesheet" href="/css/design-system.css">
|
||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
<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">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js?v=3"></script>
|
<script src="/js/api-base.js?v=3"></script>
|
||||||
<script src="/js/app-init.js?v=9" defer></script>
|
<script src="/js/app-init.js?v=9" defer></script>
|
||||||
|
|||||||
@@ -792,7 +792,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="eqMapCanvasWrap" class="hidden relative">
|
<div id="eqMapCanvasWrap" class="hidden relative">
|
||||||
<canvas id="eqMapCanvas" style="max-width:100%; border:1px solid #e2e8f0; border-radius:8px; cursor:crosshair;"></canvas>
|
<canvas id="eqMapCanvas" style="max-width:100%; border:1px solid #e2e8f0; border-radius:8px; cursor:crosshair;"></canvas>
|
||||||
<p class="text-xs text-gray-400 mt-1">클릭하여 설비 위치 지정 | 설비 아이콘 위에서 드래그하여 이동</p>
|
<p class="text-xs text-gray-400 mt-1"><i class="fas fa-info-circle mr-1"></i>설비를 드래그하여 위치 이동 | 목록의 <i class="fas fa-map-pin"></i> 버튼으로 새 위치 지정</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1249,7 +1249,7 @@
|
|||||||
<!-- 캔버스 영역 -->
|
<!-- 캔버스 영역 -->
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">영역 그리기 <span class="text-xs text-gray-400 font-normal">(캔버스 위에서 드래그하여 사각형 영역을 그리세요)</span></h4>
|
<h4 class="text-sm font-semibold text-gray-700 mb-3">영역 그리기 <span class="text-xs text-gray-400 font-normal">(캔버스 위에서 드래그하여 사각형 영역을 그리세요)</span></h4>
|
||||||
<canvas id="regionCanvas" width="800" height="400" style="border:1px solid #e2e8f0; border-radius:8px; cursor:crosshair; max-width:100%;"></canvas>
|
<canvas id="regionCanvas" style="border:1px solid #e2e8f0; border-radius:8px; cursor:crosshair; max-width:100%;"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 작업장 선택 + 저장 -->
|
<!-- 작업장 선택 + 저장 -->
|
||||||
@@ -1396,10 +1396,10 @@
|
|||||||
<script src="/static/js/tkuser-projects.js?v=20260224"></script>
|
<script src="/static/js/tkuser-projects.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-departments.js?v=20260224"></script>
|
<script src="/static/js/tkuser-departments.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-issue-types.js?v=20260224"></script>
|
<script src="/static/js/tkuser-issue-types.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-workplaces.js?v=20260224"></script>
|
<script src="/static/js/tkuser-workplaces.js?v=20260305"></script>
|
||||||
<script src="/static/js/tkuser-tasks.js?v=20260224"></script>
|
<script src="/static/js/tkuser-tasks.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-vacations.js?v=20260224"></script>
|
<script src="/static/js/tkuser-vacations.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-layout-map.js?v=20260224"></script>
|
<script src="/static/js/tkuser-layout-map.js?v=20260305"></script>
|
||||||
<!-- Boot -->
|
<!-- Boot -->
|
||||||
<script>init();</script>
|
<script>init();</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -300,10 +300,46 @@ function renderRegionList() {
|
|||||||
<span class="text-sm font-semibold text-gray-800">${r.workplace_name || ''}</span>
|
<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>
|
<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>
|
</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>';
|
</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) {
|
async function deleteRegion(regionId) {
|
||||||
if (!confirm('이 영역을 삭제하시겠습니까?')) return;
|
if (!confirm('이 영역을 삭제하시겠습니까?')) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -278,7 +278,9 @@ function displayEquipments() {
|
|||||||
if (typeFilter) filtered = filtered.filter(e => e.equipment_type === typeFilter);
|
if (typeFilter) filtered = filtered.filter(e => e.equipment_type === typeFilter);
|
||||||
const c = document.getElementById('equipmentList');
|
const c = document.getElementById('equipmentList');
|
||||||
if (!filtered.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">설비가 없습니다.</p>'; return; }
|
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 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="flex-1 min-w-0">
|
||||||
<div class="text-sm font-medium text-gray-800 truncate">
|
<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.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>` : ''}
|
${e.manufacturer ? `<span class="text-gray-400">${e.manufacturer}</span>` : ''}
|
||||||
${eqStatusBadge(e.status)}
|
${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>
|
</div>
|
||||||
<div class="flex gap-1 ml-2 flex-shrink-0">
|
<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(); 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>
|
<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>
|
||||||
</div>`).join('');
|
</div>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterEquipments() { displayEquipments(); }
|
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 imgUrl = wp.layout_image.startsWith('/') ? '/uploads/' + wp.layout_image.replace(/^\/uploads\//, '') : wp.layout_image;
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = function() {
|
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;
|
eqMapCanvas.width = img.width * scale; eqMapCanvas.height = img.height * scale;
|
||||||
eqMapCtx.drawImage(img, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
|
eqMapCtx.drawImage(img, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
|
||||||
eqMapImg = img;
|
eqMapImg = img;
|
||||||
drawEqMapEquipments();
|
drawEqMapEquipments();
|
||||||
eqMapCanvas.onclick = onEqMapClick;
|
initEqMapEvents();
|
||||||
};
|
};
|
||||||
img.src = imgUrl;
|
img.src = imgUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 좌표계: map_x_percent/map_y_percent = 좌상단 기준 (tkfb CSS left/top 과 동일)
|
||||||
|
const EQ_DEFAULT_W = 8, EQ_DEFAULT_H = 6;
|
||||||
|
|
||||||
function drawEqMapEquipments() {
|
function drawEqMapEquipments() {
|
||||||
if (!eqMapCanvas || !eqMapCtx || !eqMapImg) return;
|
if (!eqMapCanvas || !eqMapCtx || !eqMapImg) return;
|
||||||
eqMapCtx.clearRect(0, 0, eqMapCanvas.width, eqMapCanvas.height);
|
eqMapCtx.clearRect(0, 0, eqMapCanvas.width, eqMapCanvas.height);
|
||||||
eqMapCtx.drawImage(eqMapImg, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
|
eqMapCtx.drawImage(eqMapImg, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
|
||||||
equipments.forEach(eq => {
|
equipments.forEach(eq => {
|
||||||
if (eq.map_x_percent == null || eq.map_y_percent == null) return;
|
const xPct = eq._dragX != null ? eq._dragX : eq.map_x_percent;
|
||||||
const x = (eq.map_x_percent / 100) * eqMapCanvas.width;
|
const yPct = eq._dragY != null ? eq._dragY : eq.map_y_percent;
|
||||||
const y = (eq.map_y_percent / 100) * eqMapCanvas.height;
|
if (xPct == null || yPct == null) return;
|
||||||
const w = ((eq.map_width_percent || 3) / 100) * eqMapCanvas.width;
|
const wPct = eq._dragW != null ? eq._dragW : (eq.map_width_percent || EQ_DEFAULT_W);
|
||||||
const h = ((eq.map_height_percent || 3) / 100) * eqMapCanvas.height;
|
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 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';
|
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 = (isActive ? color + '55' : color + '33');
|
||||||
eqMapCtx.fillStyle = color; eqMapCtx.font = 'bold 10px sans-serif'; eqMapCtx.textAlign = 'center';
|
eqMapCtx.fillRect(left, top, w, h);
|
||||||
eqMapCtx.fillText(eq.equipment_code || eq.equipment_name, x, y - h/2 - 3);
|
// 테두리
|
||||||
eqMapCtx.textAlign = 'start';
|
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 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) {
|
function onEqMapClick(e) {
|
||||||
if (!eqMapPlacingId) return;
|
if (!eqMapPlacingId) return;
|
||||||
const r = eqMapCanvas.getBoundingClientRect();
|
const { px, py } = getCanvasXY(e);
|
||||||
const scaleX = eqMapCanvas.width / r.width; const scaleY = eqMapCanvas.height / r.height;
|
// 클릭 위치 = 좌상단 기준, 기본 크기 8x6 (tkfb 동일)
|
||||||
const px = (e.clientX - r.left) * scaleX; const py = (e.clientY - r.top) * scaleY;
|
const xPct = (px / eqMapCanvas.width * 100).toFixed(2);
|
||||||
const xPct = (px / eqMapCanvas.width * 100).toFixed(2); const yPct = (py / eqMapCanvas.height * 100).toFixed(2);
|
const yPct = (py / eqMapCanvas.height * 100).toFixed(2);
|
||||||
saveEqMapPosition(eqMapPlacingId, xPct, yPct);
|
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';
|
eqMapPlacingId = null; eqMapCanvas.style.cursor = 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEqMapPosition(eqId, x, y) {
|
async function saveEqMapFull(eqId, x, y, w, h) {
|
||||||
try {
|
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();
|
showToast('설비 위치가 저장되었습니다.'); await loadEquipments();
|
||||||
} catch(e) { showToast(e.message, 'error'); }
|
} 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) {
|
function startPlaceEquipment(eqId) {
|
||||||
eqMapPlacingId = eqId; if(eqMapCanvas) eqMapCanvas.style.cursor = 'crosshair';
|
eqMapPlacingId = eqId;
|
||||||
showToast('배치도에서 위치를 클릭하세요');
|
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() {
|
async function uploadWorkplaceLayoutImage() {
|
||||||
|
|||||||
Reference in New Issue
Block a user