feat: TBM 모바일 시스템 + 작업 분할/이동 + 권한 통합
TBM 시스템: - 4단계 워크플로우 (draft→세부편집→완료→작업보고) - 모바일 전용 TBM 페이지 (tbm-mobile.html) + 3단계 생성 위자드 - 작업자 작업 분할 (work_hours + split_seq) - 작업자 이동 보내기/빼오기 (tbm_transfers 테이블) - 생성 시 중복 배정 방지 (당일 배정 현황 조회) - 데스크탑 TBM 페이지 세부편집 기능 추가 작업보고서: - 모바일 전용 작업보고서 페이지 (report-create-mobile.html) - TBM에서 사전 등록된 work_hours 자동 반영 권한 시스템: - tkuser user_page_permissions 테이블과 system1 페이지 접근 연동 - pageAccessRoutes를 userRoutes보다 먼저 등록 (라우트 우선순위 수정) - TKUSER_DEFAULT_ACCESS 폴백 추가 (개인→부서→기본값 3단계) - 권한 캐시키 갱신 (userPageAccess_v2) 기타: - app-init.js 캐시 버스팅 (v=5) - iOS Safari touch-action: manipulation 적용 - KST 타임존 날짜 버그 수정 (toISOString UTC 이슈) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -517,15 +517,19 @@ function createSessionCard(session) {
|
||||
'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
|
||||
}[session.status] || '';
|
||||
|
||||
// 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시)
|
||||
const leaderName = escapeHtml(session.leader_name || session.created_by_name || '작업 책임자');
|
||||
const leaderRole = escapeHtml(session.leader_name
|
||||
? (session.leader_job_type || '작업자')
|
||||
: '관리자');
|
||||
const safeSessionId = parseInt(session.session_id) || 0;
|
||||
|
||||
// 카드 클릭 동작: draft → 세부 편집, completed → 상세 보기
|
||||
const onClickAction = session.status === 'draft'
|
||||
? `openTeamCompositionModal(${safeSessionId})`
|
||||
: `viewTbmSession(${safeSessionId})`;
|
||||
|
||||
return `
|
||||
<div class="tbm-session-card" onclick="viewTbmSession(${safeSessionId})">
|
||||
<div class="tbm-session-card" onclick="${onClickAction}">
|
||||
<div class="tbm-card-header">
|
||||
<div class="tbm-card-header-top">
|
||||
<div>
|
||||
@@ -558,7 +562,7 @@ function createSessionCard(session) {
|
||||
</div>
|
||||
<div class="tbm-card-info-item">
|
||||
<span class="tbm-card-info-label">팀원</span>
|
||||
<span class="tbm-card-info-value">${parseInt(session.team_member_count) || 0}명</span>
|
||||
<span class="tbm-card-info-value">${escapeHtml(session.team_member_names || '')}${session.team_member_names ? '' : '없음'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -566,7 +570,7 @@ function createSessionCard(session) {
|
||||
${session.status === 'draft' ? `
|
||||
<div class="tbm-card-footer">
|
||||
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
|
||||
👥 수정
|
||||
👥 세부 편집
|
||||
</button>
|
||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
|
||||
✓ 안전 체크
|
||||
@@ -580,10 +584,16 @@ function createSessionCard(session) {
|
||||
`;
|
||||
}
|
||||
|
||||
// 새 TBM 모달 열기
|
||||
// 새 TBM 모달 열기 (간소화: 프로젝트+공정+작업자만)
|
||||
function openNewTbmModal() {
|
||||
if (window.innerWidth <= 768) {
|
||||
window.location.href = '/pages/work/tbm-create.html';
|
||||
return;
|
||||
}
|
||||
currentSessionId = null;
|
||||
workerTaskList = []; // 작업자 목록 초기화
|
||||
workerTaskList = [];
|
||||
selectedWorkersForNewTbm = new Set();
|
||||
todayAssignmentsMap = null; // 배정 현황 캐시 초기화
|
||||
|
||||
document.getElementById('modalTitle').innerHTML = '<span>📝</span> 새 TBM 시작';
|
||||
document.getElementById('sessionId').value = '';
|
||||
@@ -610,19 +620,140 @@ function openNewTbmModal() {
|
||||
document.getElementById('leaderId').value = worker.worker_id;
|
||||
}
|
||||
} else if (currentUser && currentUser.name) {
|
||||
// 관리자: 이름만 표시
|
||||
document.getElementById('leaderName').textContent = currentUser.name;
|
||||
document.getElementById('leaderId').value = '';
|
||||
}
|
||||
|
||||
// 작업자 목록 UI 초기화
|
||||
renderWorkerTaskList();
|
||||
// 프로젝트 드롭다운 채우기
|
||||
const projSelect = document.getElementById('newTbmProjectId');
|
||||
if (projSelect) {
|
||||
projSelect.innerHTML = '<option value="">선택 안함</option>' +
|
||||
allProjects.map(p => `<option value="${p.project_id}">${escapeHtml(p.project_name)} (${escapeHtml(p.job_no || '')})</option>`).join('');
|
||||
}
|
||||
|
||||
// 공정 드롭다운 채우기
|
||||
const wtSelect = document.getElementById('newTbmWorkTypeId');
|
||||
if (wtSelect) {
|
||||
wtSelect.innerHTML = '<option value="">공정 선택...</option>' +
|
||||
allWorkTypes.map(wt => `<option value="${wt.id}">${escapeHtml(wt.name)}</option>`).join('');
|
||||
}
|
||||
|
||||
// 작업자 체크박스 그리드 렌더링
|
||||
renderNewTbmWorkerGrid();
|
||||
|
||||
document.getElementById('tbmModal').style.display = 'flex';
|
||||
lockBodyScroll();
|
||||
}
|
||||
window.openNewTbmModal = openNewTbmModal;
|
||||
|
||||
// 새 TBM 모달용 작업자 선택 세트
|
||||
let selectedWorkersForNewTbm = new Set();
|
||||
let todayAssignmentsMap = null; // 당일 배정 현황
|
||||
|
||||
// 작업자 그리드 렌더링
|
||||
async function renderNewTbmWorkerGrid() {
|
||||
const grid = document.getElementById('newTbmWorkerGrid');
|
||||
if (!grid) return;
|
||||
|
||||
// 당일 배정 현황 로드
|
||||
if (!todayAssignmentsMap) {
|
||||
try {
|
||||
const today = getTodayKST();
|
||||
const res = await apiCall(`/tbm/sessions/date/${today}/assignments`);
|
||||
todayAssignmentsMap = {};
|
||||
if (res && res.success) {
|
||||
res.data.forEach(a => {
|
||||
if (a.sessions && a.sessions.length > 0) {
|
||||
todayAssignmentsMap[a.worker_id] = a;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('배정 현황 로드 오류:', e);
|
||||
todayAssignmentsMap = {};
|
||||
}
|
||||
}
|
||||
|
||||
grid.innerHTML = allWorkers.map(w => {
|
||||
const checked = selectedWorkersForNewTbm.has(w.worker_id) ? 'checked' : '';
|
||||
const assignment = todayAssignmentsMap[w.worker_id];
|
||||
const fullyAssigned = assignment && assignment.total_hours >= 8;
|
||||
const partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8;
|
||||
|
||||
let badgeHtml = '';
|
||||
let disabledAttr = '';
|
||||
let disabledStyle = '';
|
||||
|
||||
if (fullyAssigned) {
|
||||
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>`;
|
||||
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 `
|
||||
<label class="tbm-worker-select-item ${checked ? 'selected' : ''}" data-wid="${w.worker_id}" style="${disabledStyle}">
|
||||
<input type="checkbox" class="new-tbm-worker-cb" data-worker-id="${w.worker_id}" ${checked} ${disabledAttr}
|
||||
onchange="toggleNewTbmWorker(${w.worker_id}, this.checked)">
|
||||
<span class="tbm-worker-name">${escapeHtml(w.worker_name)}</span>
|
||||
<span class="tbm-worker-role">${escapeHtml(w.job_type || '작업자')}</span>
|
||||
${badgeHtml}
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
updateNewTbmWorkerCount();
|
||||
}
|
||||
|
||||
function updateNewTbmWorkerCount() {
|
||||
const countEl = document.getElementById('newTbmWorkerCount');
|
||||
if (countEl) countEl.textContent = `(${selectedWorkersForNewTbm.size}명)`;
|
||||
}
|
||||
|
||||
function toggleNewTbmWorker(workerId, checked) {
|
||||
// 종일 배정된 작업자 선택 방지
|
||||
const a = todayAssignmentsMap && todayAssignmentsMap[workerId];
|
||||
if (a && a.total_hours >= 8) return;
|
||||
|
||||
if (checked) {
|
||||
selectedWorkersForNewTbm.add(workerId);
|
||||
} else {
|
||||
selectedWorkersForNewTbm.delete(workerId);
|
||||
}
|
||||
// Update visual state
|
||||
const label = document.querySelector(`#newTbmWorkerGrid label[data-wid="${workerId}"]`);
|
||||
if (label) label.classList.toggle('selected', checked);
|
||||
updateNewTbmWorkerCount();
|
||||
}
|
||||
window.toggleNewTbmWorker = toggleNewTbmWorker;
|
||||
|
||||
function selectAllNewTbmWorkers() {
|
||||
allWorkers.forEach(w => {
|
||||
const a = todayAssignmentsMap && todayAssignmentsMap[w.worker_id];
|
||||
if (a && a.total_hours >= 8) return; // 종일 배정 제외
|
||||
selectedWorkersForNewTbm.add(w.worker_id);
|
||||
});
|
||||
document.querySelectorAll('.new-tbm-worker-cb').forEach(cb => {
|
||||
if (!cb.disabled) cb.checked = true;
|
||||
});
|
||||
document.querySelectorAll('#newTbmWorkerGrid label').forEach(l => {
|
||||
if (l.style.opacity !== '0.5') l.classList.add('selected');
|
||||
});
|
||||
updateNewTbmWorkerCount();
|
||||
}
|
||||
window.selectAllNewTbmWorkers = selectAllNewTbmWorkers;
|
||||
|
||||
function deselectAllNewTbmWorkers() {
|
||||
selectedWorkersForNewTbm.clear();
|
||||
document.querySelectorAll('.new-tbm-worker-cb').forEach(cb => { cb.checked = false; });
|
||||
document.querySelectorAll('#newTbmWorkerGrid label').forEach(l => l.classList.remove('selected'));
|
||||
updateNewTbmWorkerCount();
|
||||
}
|
||||
window.deselectAllNewTbmWorkers = deselectAllNewTbmWorkers;
|
||||
|
||||
// 입력자 선택 드롭다운 채우기
|
||||
function populateLeaderSelect() {
|
||||
const leaderSelect = document.getElementById('leaderId');
|
||||
@@ -729,13 +860,12 @@ function closeTbmModal() {
|
||||
}
|
||||
window.closeTbmModal = closeTbmModal;
|
||||
|
||||
// TBM 세션 저장 (작업자별 상세 정보 포함)
|
||||
// TBM 세션 저장 (간소화: 프로젝트+공정+작업자, task/workplace=null)
|
||||
async function saveTbmSession() {
|
||||
console.log('💾 TBM 저장 시작...');
|
||||
|
||||
let leaderId = parseInt(document.getElementById('leaderId').value);
|
||||
|
||||
// 관리자 계정인 경우 leader_id를 null로 설정
|
||||
if (!leaderId || isNaN(leaderId)) {
|
||||
if (!currentUser.worker_id) {
|
||||
console.log('📝 관리자 계정: leader_id를 NULL로 설정');
|
||||
@@ -752,66 +882,39 @@ async function saveTbmSession() {
|
||||
leader_id: leaderId
|
||||
};
|
||||
|
||||
console.log('📅 세션 데이터:', sessionData);
|
||||
console.log('👥 작업자 리스트:', workerTaskList);
|
||||
console.log('👤 현재 사용자:', currentUser);
|
||||
|
||||
if (!sessionData.session_date) {
|
||||
console.error('❌ 날짜 누락');
|
||||
showToast('TBM 날짜를 확인해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (workerTaskList.length === 0) {
|
||||
console.error('❌ 작업자 리스트가 비어있음');
|
||||
showToast('최소 1명 이상의 작업자를 추가해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
const editingSessionId = document.getElementById('sessionId').value;
|
||||
|
||||
// 필수 항목 검증 (공정, 작업, 작업장)
|
||||
let hasError = false;
|
||||
for (const workerData of workerTaskList) {
|
||||
for (const taskLine of workerData.tasks) {
|
||||
if (!taskLine.work_type_id || !taskLine.task_id || !taskLine.workplace_id) {
|
||||
showToast(`${workerData.worker_name}의 공정, 작업, 작업장을 모두 선택해주세요.`, 'error');
|
||||
hasError = true;
|
||||
break;
|
||||
// 수정 모드일 때는 기존 openTeamCompositionModal의 workerTaskList를 사용
|
||||
if (editingSessionId) {
|
||||
// 기존 수정 모드 로직 (openTeamCompositionModal 경유)
|
||||
if (workerTaskList.length === 0) {
|
||||
showToast('최소 1명 이상의 작업자를 추가해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const members = [];
|
||||
for (const workerData of workerTaskList) {
|
||||
for (const taskLine of workerData.tasks) {
|
||||
members.push({
|
||||
worker_id: workerData.worker_id,
|
||||
project_id: taskLine.project_id || null,
|
||||
work_type_id: taskLine.work_type_id,
|
||||
task_id: taskLine.task_id,
|
||||
workplace_category_id: taskLine.workplace_category_id || null,
|
||||
workplace_id: taskLine.workplace_id,
|
||||
work_detail: taskLine.work_detail || null,
|
||||
is_present: taskLine.is_present !== undefined ? taskLine.is_present : true
|
||||
});
|
||||
}
|
||||
}
|
||||
if (hasError) break;
|
||||
}
|
||||
if (hasError) return;
|
||||
|
||||
// 작업자-작업 데이터를 평평하게 변환
|
||||
const members = [];
|
||||
for (const workerData of workerTaskList) {
|
||||
for (const taskLine of workerData.tasks) {
|
||||
members.push({
|
||||
worker_id: workerData.worker_id,
|
||||
project_id: taskLine.project_id || null,
|
||||
work_type_id: taskLine.work_type_id,
|
||||
task_id: taskLine.task_id,
|
||||
workplace_category_id: taskLine.workplace_category_id || null,
|
||||
workplace_id: taskLine.workplace_id,
|
||||
work_detail: taskLine.work_detail || null,
|
||||
is_present: taskLine.is_present !== undefined ? taskLine.is_present : true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📤 전송할 팀 데이터:', members);
|
||||
|
||||
try {
|
||||
const editingSessionId = document.getElementById('sessionId').value;
|
||||
|
||||
if (editingSessionId) {
|
||||
// 수정 모드: 기존 팀원 삭제 후 재등록
|
||||
console.log('📝 TBM 수정 모드:', editingSessionId);
|
||||
|
||||
// 기존 팀원 삭제
|
||||
try {
|
||||
await window.apiCall(`/tbm/sessions/${editingSessionId}/team/clear`, 'DELETE');
|
||||
|
||||
// 새 팀원 일괄 추가
|
||||
const teamResponse = await window.apiCall(
|
||||
`/tbm/sessions/${editingSessionId}/team/batch`,
|
||||
'POST',
|
||||
@@ -819,51 +922,79 @@ async function saveTbmSession() {
|
||||
);
|
||||
|
||||
if (teamResponse && teamResponse.success) {
|
||||
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length}건)`, 'success');
|
||||
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명)`, 'success');
|
||||
closeTbmModal();
|
||||
|
||||
// 목록 새로고침
|
||||
if (currentTab === 'tbm-input') {
|
||||
await loadTodayOnlyTbm();
|
||||
} else {
|
||||
await loadTbmSessionsByDate(sessionData.session_date);
|
||||
await loadRecentTbmGroupedByDate();
|
||||
}
|
||||
} else {
|
||||
throw new Error(teamResponse.message || '팀원 수정에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 생성 모드: 새 TBM 세션 생성
|
||||
console.log('✨ TBM 생성 모드');
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 세션 수정 오류:', error);
|
||||
showToast('TBM 세션 수정 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
|
||||
// 생성 모드: 간소화된 새 TBM
|
||||
const workTypeId = parseInt(document.getElementById('newTbmWorkTypeId')?.value);
|
||||
const projectId = parseInt(document.getElementById('newTbmProjectId')?.value) || null;
|
||||
|
||||
if (response && response.success) {
|
||||
const createdSessionId = response.data.session_id;
|
||||
console.log('✅ TBM 세션 생성 완료:', createdSessionId);
|
||||
if (!workTypeId) {
|
||||
showToast('공정을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업자 일괄 추가
|
||||
const teamResponse = await window.apiCall(
|
||||
`/tbm/sessions/${createdSessionId}/team/batch`,
|
||||
'POST',
|
||||
{ members }
|
||||
);
|
||||
if (selectedWorkersForNewTbm.size === 0) {
|
||||
showToast('최소 1명 이상의 작업자를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (teamResponse && teamResponse.success) {
|
||||
showToast(`TBM이 생성되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length}건)`, 'success');
|
||||
closeTbmModal();
|
||||
// 작업자별 members 생성 (task_id, workplace_id = null)
|
||||
const members = [];
|
||||
selectedWorkersForNewTbm.forEach(workerId => {
|
||||
members.push({
|
||||
worker_id: workerId,
|
||||
project_id: projectId,
|
||||
work_type_id: workTypeId,
|
||||
task_id: null,
|
||||
workplace_category_id: null,
|
||||
workplace_id: null,
|
||||
work_detail: null,
|
||||
is_present: true
|
||||
});
|
||||
});
|
||||
|
||||
// 목록 새로고침
|
||||
if (currentTab === 'tbm-input') {
|
||||
await loadTodayOnlyTbm();
|
||||
} else {
|
||||
await loadTbmSessionsByDate(sessionData.session_date);
|
||||
}
|
||||
try {
|
||||
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
|
||||
|
||||
if (response && response.success) {
|
||||
const createdSessionId = response.data.session_id;
|
||||
console.log('✅ TBM 세션 생성 완료:', createdSessionId);
|
||||
|
||||
const teamResponse = await window.apiCall(
|
||||
`/tbm/sessions/${createdSessionId}/team/batch`,
|
||||
'POST',
|
||||
{ members }
|
||||
);
|
||||
|
||||
if (teamResponse && teamResponse.success) {
|
||||
showToast(`TBM이 생성되었습니다 (작업자 ${members.length}명)`, 'success');
|
||||
closeTbmModal();
|
||||
|
||||
if (currentTab === 'tbm-input') {
|
||||
await loadTodayOnlyTbm();
|
||||
} else {
|
||||
throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.');
|
||||
await loadRecentTbmGroupedByDate();
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || '저장에 실패했습니다.');
|
||||
throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 세션 저장 오류:', error);
|
||||
@@ -1502,6 +1633,11 @@ window.openWorkplaceSelect = openWorkplaceSelect;
|
||||
|
||||
// 작업장 선택 모달 닫기
|
||||
function closeWorkplaceSelectModal() {
|
||||
// 가로모드 오버레이도 닫기
|
||||
const landscapeOverlay = document.getElementById('landscapeOverlay');
|
||||
if (landscapeOverlay && landscapeOverlay.style.display !== 'none') {
|
||||
closeLandscapeMap();
|
||||
}
|
||||
document.getElementById('workplaceSelectModal').style.display = 'none';
|
||||
unlockBodyScroll();
|
||||
document.getElementById('workplaceSelectionArea').style.display = 'none';
|
||||
@@ -1578,6 +1714,9 @@ async function selectCategory(categoryId, categoryName) {
|
||||
document.getElementById('workplaceListSection').style.display = 'none';
|
||||
document.getElementById('toggleListBtn').style.display = 'inline-flex';
|
||||
document.getElementById('toggleListBtn').textContent = '리스트로 선택';
|
||||
// 전체화면 지도 버튼 표시
|
||||
const triggerBtn = document.getElementById('landscapeTriggerBtn');
|
||||
if (triggerBtn) triggerBtn.style.display = 'inline-flex';
|
||||
} else {
|
||||
// 데스크톱: 리스트도 함께 표시
|
||||
document.getElementById('workplaceList').style.display = 'flex';
|
||||
@@ -1876,6 +2015,178 @@ function syncWorkplaceListSelection(workplaceId) {
|
||||
}
|
||||
window.syncWorkplaceListSelection = syncWorkplaceListSelection;
|
||||
|
||||
// ==================== 가로모드 전체화면 지도 ====================
|
||||
|
||||
function openLandscapeMap() {
|
||||
if (!mapImage || !mapImage.complete || mapRegions.length === 0) return;
|
||||
|
||||
const overlay = document.getElementById('landscapeOverlay');
|
||||
const inner = document.getElementById('landscapeInner');
|
||||
const lCanvas = document.getElementById('landscapeCanvas');
|
||||
if (!overlay || !lCanvas) return;
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
lockBodyScroll();
|
||||
|
||||
// 물리적 가로모드 여부 판단
|
||||
const isPhysicalLandscape = window.innerWidth > window.innerHeight;
|
||||
inner.className = 'landscape-inner ' + (isPhysicalLandscape ? 'no-rotate' : 'rotated');
|
||||
|
||||
// 가용 영역 계산 (헤더 52px, 패딩 여유)
|
||||
const headerH = 52;
|
||||
const pad = 16;
|
||||
let availW, availH;
|
||||
if (isPhysicalLandscape) {
|
||||
availW = window.innerWidth - pad * 2;
|
||||
availH = window.innerHeight - headerH - pad * 2;
|
||||
} else {
|
||||
// 회전: 가로↔세로 스왑
|
||||
availW = window.innerHeight - pad * 2;
|
||||
availH = window.innerWidth - headerH - pad * 2;
|
||||
}
|
||||
|
||||
// 이미지 비율 유지 캔버스 크기
|
||||
const imgRatio = mapImage.naturalWidth / mapImage.naturalHeight;
|
||||
let cw, ch;
|
||||
if (availW / availH > imgRatio) {
|
||||
ch = availH;
|
||||
cw = ch * imgRatio;
|
||||
} else {
|
||||
cw = availW;
|
||||
ch = cw / imgRatio;
|
||||
}
|
||||
lCanvas.width = Math.round(cw);
|
||||
lCanvas.height = Math.round(ch);
|
||||
|
||||
drawLandscapeMap();
|
||||
|
||||
// 이벤트 리스너
|
||||
lCanvas.ontouchstart = handleLandscapeTouchStart;
|
||||
lCanvas.onclick = handleLandscapeClick;
|
||||
}
|
||||
window.openLandscapeMap = openLandscapeMap;
|
||||
|
||||
function drawLandscapeMap() {
|
||||
const lCanvas = document.getElementById('landscapeCanvas');
|
||||
if (!lCanvas || !mapImage) return;
|
||||
const lCtx = lCanvas.getContext('2d');
|
||||
|
||||
lCtx.drawImage(mapImage, 0, 0, lCanvas.width, lCanvas.height);
|
||||
|
||||
mapRegions.forEach(region => {
|
||||
const x1 = (region.x_start / 100) * lCanvas.width;
|
||||
const y1 = (region.y_start / 100) * lCanvas.height;
|
||||
const x2 = (region.x_end / 100) * lCanvas.width;
|
||||
const y2 = (region.y_end / 100) * lCanvas.height;
|
||||
const w = x2 - x1;
|
||||
const h = y2 - y1;
|
||||
const isSelected = region.workplace_id === selectedWorkplace;
|
||||
|
||||
lCtx.strokeStyle = isSelected ? '#3b82f6' : '#10b981';
|
||||
lCtx.lineWidth = isSelected ? 4 : 2;
|
||||
lCtx.strokeRect(x1, y1, w, h);
|
||||
|
||||
lCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.25)' : 'rgba(16, 185, 129, 0.15)';
|
||||
lCtx.fillRect(x1, y1, w, h);
|
||||
|
||||
if (region.workplace_name) {
|
||||
lCtx.font = 'bold 14px sans-serif';
|
||||
const tm = lCtx.measureText(region.workplace_name);
|
||||
const tp = 6;
|
||||
lCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.95)' : 'rgba(16, 185, 129, 0.95)';
|
||||
lCtx.fillRect(x1 + 5, y1 + 5, tm.width + tp * 2, 24);
|
||||
lCtx.fillStyle = '#ffffff';
|
||||
lCtx.textAlign = 'left';
|
||||
lCtx.textBaseline = 'alphabetic';
|
||||
lCtx.fillText(region.workplace_name, x1 + 5 + tp, y1 + 22);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getLandscapeCoords(clientX, clientY) {
|
||||
const lCanvas = document.getElementById('landscapeCanvas');
|
||||
if (!lCanvas) return null;
|
||||
const rect = lCanvas.getBoundingClientRect();
|
||||
const inner = document.getElementById('landscapeInner');
|
||||
const isRotated = inner.classList.contains('rotated');
|
||||
|
||||
if (!isRotated) {
|
||||
// 회전 없음 - 일반 좌표
|
||||
const scaleX = lCanvas.width / rect.width;
|
||||
const scaleY = lCanvas.height / rect.height;
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY
|
||||
};
|
||||
}
|
||||
|
||||
// 90° 시계방향 회전의 역변환
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const dx = clientX - centerX;
|
||||
const dy = clientY - centerY;
|
||||
// 역회전 (반시계 90°)
|
||||
const inverseDx = dy;
|
||||
const inverseDy = -dx;
|
||||
|
||||
// 회전 전 실제 크기: rect가 회전된 후이므로 width↔height 스왑
|
||||
const unrotatedW = rect.height;
|
||||
const unrotatedH = rect.width;
|
||||
|
||||
const canvasX = (inverseDx + unrotatedW / 2) / unrotatedW * lCanvas.width;
|
||||
const canvasY = (inverseDy + unrotatedH / 2) / unrotatedH * lCanvas.height;
|
||||
|
||||
return { x: canvasX, y: canvasY };
|
||||
}
|
||||
|
||||
function handleLandscapeTouchStart(e) {
|
||||
e.preventDefault(); // 고스트 클릭 방지
|
||||
const touch = e.touches[0];
|
||||
const coords = getLandscapeCoords(touch.clientX, touch.clientY);
|
||||
if (coords) doLandscapeHitTest(coords.x, coords.y);
|
||||
}
|
||||
|
||||
function handleLandscapeClick(e) {
|
||||
const coords = getLandscapeCoords(e.clientX, e.clientY);
|
||||
if (coords) doLandscapeHitTest(coords.x, coords.y);
|
||||
}
|
||||
|
||||
function doLandscapeHitTest(cx, cy) {
|
||||
const lCanvas = document.getElementById('landscapeCanvas');
|
||||
if (!lCanvas) return;
|
||||
|
||||
for (let i = mapRegions.length - 1; i >= 0; i--) {
|
||||
const region = mapRegions[i];
|
||||
const x1 = (region.x_start / 100) * lCanvas.width;
|
||||
const y1 = (region.y_start / 100) * lCanvas.height;
|
||||
const x2 = (region.x_end / 100) * lCanvas.width;
|
||||
const y2 = (region.y_end / 100) * lCanvas.height;
|
||||
|
||||
if (cx >= x1 && cx <= x2 && cy >= y1 && cy <= y2) {
|
||||
selectWorkplace(region.workplace_id, region.workplace_name);
|
||||
drawWorkplaceMap();
|
||||
syncWorkplaceListSelection(region.workplace_id);
|
||||
|
||||
// 하이라이트 후 자동 닫기
|
||||
drawLandscapeMap();
|
||||
setTimeout(() => closeLandscapeMap(), 300);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeLandscapeMap() {
|
||||
const overlay = document.getElementById('landscapeOverlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
const lCanvas = document.getElementById('landscapeCanvas');
|
||||
if (lCanvas) {
|
||||
lCanvas.ontouchstart = null;
|
||||
lCanvas.onclick = null;
|
||||
}
|
||||
unlockBodyScroll();
|
||||
}
|
||||
window.closeLandscapeMap = closeLandscapeMap;
|
||||
|
||||
// ==================== 기존 팀 구성 모달 (백업) ====================
|
||||
|
||||
// 팀 구성 모달 열기
|
||||
@@ -2274,8 +2585,11 @@ async function saveSafetyChecklist() {
|
||||
}
|
||||
window.saveSafetyChecklist = saveSafetyChecklist;
|
||||
|
||||
// TBM 완료 모달용 팀원 데이터
|
||||
let completeModalTeam = [];
|
||||
|
||||
// TBM 완료 모달 열기
|
||||
function openCompleteTbmModal(sessionId) {
|
||||
async function openCompleteTbmModal(sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
const now = new Date();
|
||||
const timeString = now.toTimeString().slice(0, 5);
|
||||
@@ -2283,9 +2597,75 @@ function openCompleteTbmModal(sessionId) {
|
||||
|
||||
document.getElementById('completeModal').style.display = 'flex';
|
||||
lockBodyScroll();
|
||||
|
||||
// 팀원 조회 → 근태 선택 렌더링
|
||||
try {
|
||||
const teamRes = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
|
||||
completeModalTeam = (teamRes && teamRes.data) ? teamRes.data : [];
|
||||
renderCompleteAttendanceList();
|
||||
} catch (e) {
|
||||
console.error('팀원 조회 오류:', e);
|
||||
document.getElementById('completeAttendanceList').innerHTML =
|
||||
'<div style="color:#ef4444; padding:0.5rem;">팀원 목록을 불러올 수 없습니다.</div>';
|
||||
}
|
||||
}
|
||||
window.openCompleteTbmModal = openCompleteTbmModal;
|
||||
|
||||
function renderCompleteAttendanceList() {
|
||||
const container = document.getElementById('completeAttendanceList');
|
||||
if (completeModalTeam.length === 0) {
|
||||
container.innerHTML = '<div style="color:#9ca3af; padding:0.5rem; text-align:center;">팀원이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<table style="width:100%; border-collapse:collapse; font-size:0.8125rem;">' +
|
||||
'<tr style="background:#f9fafb;"><th style="padding:0.5rem; text-align:left;">작업자</th><th style="padding:0.5rem; text-align:left;">직종</th><th style="padding:0.5rem; text-align:left;">근태</th><th style="padding:0.5rem; text-align:center;">추가</th></tr>';
|
||||
completeModalTeam.forEach((m, i) => {
|
||||
html += `<tr style="border-top:1px solid #f3f4f6;">
|
||||
<td style="padding:0.5rem; font-weight:600;">${m.worker_name || ''}</td>
|
||||
<td style="padding:0.5rem; color:#6b7280;">${m.job_type || '-'}</td>
|
||||
<td style="padding:0.5rem;">
|
||||
<select id="catt_type_${i}" onchange="onCompleteAttChange(${i})" style="padding:0.375rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.8125rem; background:white;">
|
||||
<option value="regular">정시근로 (8h)</option>
|
||||
<option value="overtime">연장근무 (8h+)</option>
|
||||
<option value="annual">연차 (휴무)</option>
|
||||
<option value="half">반차 (4h)</option>
|
||||
<option value="quarter">반반차 (6h)</option>
|
||||
<option value="early">조퇴</option>
|
||||
</select>
|
||||
</td>
|
||||
<td style="padding:0.5rem; text-align:center;">
|
||||
<input type="number" id="catt_hours_${i}" step="0.5" min="0" max="8" style="display:none; width:60px; padding:0.375rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.8125rem; text-align:center;">
|
||||
<span id="catt_hint_${i}" style="font-size:0.75rem; color:#6b7280;"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
window.onCompleteAttChange = function(idx) {
|
||||
const sel = document.getElementById('catt_type_' + idx);
|
||||
const inp = document.getElementById('catt_hours_' + idx);
|
||||
const hint = document.getElementById('catt_hint_' + idx);
|
||||
const val = sel.value;
|
||||
if (val === 'overtime') {
|
||||
inp.style.display = 'inline-block';
|
||||
inp.placeholder = '+h';
|
||||
inp.value = '';
|
||||
hint.textContent = '';
|
||||
} else if (val === 'early') {
|
||||
inp.style.display = 'inline-block';
|
||||
inp.placeholder = '시간';
|
||||
inp.value = '';
|
||||
hint.textContent = '';
|
||||
} else {
|
||||
inp.style.display = 'none';
|
||||
inp.value = '';
|
||||
const labels = { regular: '8h', annual: '자동처리', half: '4h', quarter: '6h' };
|
||||
hint.textContent = labels[val] || '';
|
||||
}
|
||||
};
|
||||
|
||||
// 완료 모달 닫기
|
||||
function closeCompleteModal() {
|
||||
document.getElementById('completeModal').style.display = 'none';
|
||||
@@ -2297,11 +2677,37 @@ window.closeCompleteModal = closeCompleteModal;
|
||||
async function completeTbmSession() {
|
||||
const endTime = document.getElementById('endTime').value;
|
||||
|
||||
// 근태 데이터 수집
|
||||
const attendanceData = [];
|
||||
for (let i = 0; i < completeModalTeam.length; i++) {
|
||||
const type = document.getElementById('catt_type_' + i).value;
|
||||
const hoursVal = document.getElementById('catt_hours_' + i).value;
|
||||
const hours = hoursVal ? parseFloat(hoursVal) : null;
|
||||
|
||||
if (type === 'overtime' && (!hours || hours <= 0)) {
|
||||
showToast(`${completeModalTeam[i].worker_name}의 추가 시간을 입력해주세요.`, 'error');
|
||||
return;
|
||||
}
|
||||
if (type === 'early' && (!hours || hours <= 0)) {
|
||||
showToast(`${completeModalTeam[i].worker_name}의 근무 시간을 입력해주세요.`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
attendanceData.push({
|
||||
worker_id: completeModalTeam[i].worker_id,
|
||||
attendance_type: type,
|
||||
attendance_hours: hours
|
||||
});
|
||||
}
|
||||
|
||||
const btn = document.getElementById('completeModalBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '처리 중...'; }
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(
|
||||
`/tbm/sessions/${currentSessionId}/complete`,
|
||||
'POST',
|
||||
{ end_time: endTime }
|
||||
{ end_time: endTime, attendance_data: attendanceData }
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
@@ -2321,6 +2727,8 @@ async function completeTbmSession() {
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 완료 처리 오류:', error);
|
||||
showToast('TBM 완료 처리 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<span class="tbm-btn-icon">✓</span> 완료'; }
|
||||
}
|
||||
}
|
||||
window.completeTbmSession = completeTbmSession;
|
||||
@@ -2365,8 +2773,8 @@ async function viewTbmSession(sessionId) {
|
||||
<div style="font-weight: 600; color: #111827;">${escapeHtml(statusText)}</div>
|
||||
</div>
|
||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
||||
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀원 수</div>
|
||||
<div style="font-weight: 600; color: #111827;">${parseInt(session.team_member_count) || team.length}명</div>
|
||||
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀원 (${parseInt(session.team_member_count) || team.length}명)</div>
|
||||
<div style="font-weight: 600; color: #111827;">${escapeHtml(session.team_member_names || team.map(t => t.worker_name).join(', ') || '없음')}</div>
|
||||
</div>
|
||||
${session.project_name ? `
|
||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
||||
|
||||
Reference in New Issue
Block a user