From 480206912b42285a0171919833f40d8136788ebf Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 20 Jan 2026 15:46:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20TBM=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=99=84=EC=84=B1=20-=20=EC=9E=91=EC=97=85=20=EC=9D=B8?= =?UTF-8?q?=EA=B3=84,=20=EC=83=81=EC=84=B8=EB=B3=B4=EA=B8=B0,=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EB=B3=B4=EA=B3=A0=EC=84=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 주요 기능 추가 ### 1. 작업 인계 시스템 (반차/조퇴 시) - **인계 모달** (`handoverModal`) - 인계 사유 선택 (반차/조퇴/긴급/기타) - 인수자 (다른 팀장) 선택 - 인계 날짜/시간 입력 - 인계할 팀원 선택 (체크박스) - 인계 내용 메모 - **API 연동** - POST /api/tbm/handovers (인계 요청 생성) - 세션 정보와 팀 구성 자동 조회 - from_leader_id 자동 설정 - **UI 개선** - TBM 카드에 "📤 인계" 버튼 추가 - 인계할 팀원 목록 자동 로드 - 현재 팀장 제외한 리더만 표시 ### 2. TBM 상세보기 모달 - **상세 정보 표시** (`detailModal`) - 기본 정보 (팀장, 날짜, 프로젝트, 작업 장소, 작업 내용) - 안전 특이사항 (노란색 강조) - 팀 구성 (그리드 레이아웃) - 안전 체크리스트 (카테고리별 그룹화) - **안전 체크 시각화** - ✅/❌ 아이콘으로 체크 상태 표시 - 체크됨: 초록색 배경 - 미체크: 빨간색 배경 - 카테고리별 구분 (PPE/EQUIPMENT/ENVIRONMENT/EMERGENCY) - **병렬 API 호출** - Promise.all로 세션/팀/안전체크 동시 조회 - 로딩 성능 최적화 ### 3. 작업 보고서와 TBM 연동 - **TBM 팀 구성 자동 불러오기** - `loadTbmTeamForDate()` 함수 추가 - 선택한 날짜의 TBM 세션 자동 조회 - 진행중(draft) 세션 우선 선택 - 팀 구성 정보 자동 로드 - **작업자 자동 선택** - TBM에서 구성한 팀원 자동 선택 - 선택된 작업자 시각적 표시 (.selected 클래스) - 다음 단계 버튼 자동 활성화 - **안내 메시지** - "🛠️ TBM 팀 구성 자동 적용" 알림 - 자동 선택된 팀원 수 표시 - 파란색 강조 스타일 ### 4. UI/UX 개선 - TBM 카드 버튼 레이아웃 개선 (flex-wrap) - 인계 버튼 오렌지색 (#f59e0b) - 모달 스크롤 가능 (max-height: 70vh) - 반응형 그리드 (auto-fill, minmax) ## 기술 구현 ### 함수 추가 - `viewTbmSession()`: 상세보기 (병렬 API 호출) - `openHandoverModal()`: 인계 모달 (팀 구성 자동 로드) - `saveHandover()`: 인계 저장 (worker_ids JSON array) - `loadTbmTeamForDate()`: TBM 팀 구성 조회 - `closeDetailModal()`, `closeHandoverModal()`: 모달 닫기 ### 수정 함수 - `populateWorkerGrid()`: TBM 연동 추가 (async/await) - `displayTbmSessions()`: 인계 버튼 추가 ## 파일 변경사항 - web-ui/pages/work/tbm.html (모달 2개 추가, 약 110줄) - web-ui/js/tbm.js (함수 추가, 약 250줄 증가) - web-ui/js/daily-work-report.js (TBM 연동, 약 60줄 추가) ## 사용 시나리오 ### 시나리오 1: TBM → 작업보고서 1. 아침 TBM에서 팀 구성 (예: 5명 선택) 2. 작업 보고서 작성 시 날짜 선택 3. **자동으로 5명 선택됨** ✨ 4. 바로 작업 내역 입력 가능 ### 시나리오 2: 조퇴 시 인계 1. TBM 카드에서 "📤 인계" 클릭 2. 사유 선택 (조퇴), 인수자 선택 3. 인계할 팀원 선택 (기본 전체 선택) 4. 인계 요청 → DB 저장 ### 시나리오 3: TBM 상세 확인 1. TBM 카드 클릭 2. 기본 정보, 팀 구성, 안전 체크 한눈에 확인 3. 안전 체크 완료 여부 시각적 확인 ## 데이터 흐름 ``` TBM 시작 ↓ 팀 구성 저장 (tbm_team_assignments) ↓ 작업 보고서 작성 시 ↓ GET /api/tbm/sessions/date/:date ↓ GET /api/tbm/sessions/:id/team ↓ 팀원 자동 선택 ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- web-ui/js/daily-work-report.js | 71 ++++++++- web-ui/js/tbm.js | 264 ++++++++++++++++++++++++++++++++- web-ui/pages/work/tbm.html | 106 ++++++++++++- 3 files changed, 433 insertions(+), 8 deletions(-) diff --git a/web-ui/js/daily-work-report.js b/web-ui/js/daily-work-report.js index 5037e64..14e9977 100644 --- a/web-ui/js/daily-work-report.js +++ b/web-ui/js/daily-work-report.js @@ -294,11 +294,68 @@ async function loadErrorTypes() { } } +// TBM 팀 구성 자동 불러오기 +async function loadTbmTeamForDate(date) { + try { + console.log('🛠️ TBM 팀 구성 조회 중:', date); + const response = await window.apiCall(`/tbm/sessions/date/${date}`); + + if (response && response.success && response.data && response.data.length > 0) { + // 가장 최근 세션 선택 (진행중인 세션 우선) + const draftSessions = response.data.filter(s => s.status === 'draft'); + const targetSession = draftSessions.length > 0 ? draftSessions[0] : response.data[0]; + + if (targetSession) { + // 팀 구성 조회 + const teamRes = await window.apiCall(`/tbm/sessions/${targetSession.session_id}/team`); + if (teamRes && teamRes.success && teamRes.data) { + const teamWorkerIds = teamRes.data.map(m => m.worker_id); + console.log(`✅ TBM 팀 구성 로드 성공: ${teamWorkerIds.length}명`); + return teamWorkerIds; + } + } + } + + console.log('ℹ️ 해당 날짜의 TBM 팀 구성이 없습니다.'); + return []; + } catch (error) { + console.error('❌ TBM 팀 구성 조회 오류:', error); + return []; + } +} + // 작업자 그리드 생성 -function populateWorkerGrid() { +async function populateWorkerGrid() { const grid = document.getElementById('workerGrid'); grid.innerHTML = ''; + // 선택된 날짜의 TBM 팀 구성 불러오기 + const reportDate = document.getElementById('reportDate').value; + let tbmWorkerIds = []; + + if (reportDate) { + tbmWorkerIds = await loadTbmTeamForDate(reportDate); + } + + // TBM 팀 구성이 있으면 안내 메시지 표시 + if (tbmWorkerIds.length > 0) { + const infoDiv = document.createElement('div'); + infoDiv.style.cssText = ` + padding: 1rem; + background: #eff6ff; + border: 1px solid #3b82f6; + border-radius: 0.5rem; + margin-bottom: 1rem; + color: #1e40af; + font-size: 0.875rem; + `; + infoDiv.innerHTML = ` + 🛠️ TBM 팀 구성 자동 적용
+ 오늘 TBM에서 구성된 팀원 ${tbmWorkerIds.length}명이 자동으로 선택되었습니다. + `; + grid.appendChild(infoDiv); + } + workers.forEach(worker => { const btn = document.createElement('button'); btn.type = 'button'; @@ -306,12 +363,24 @@ function populateWorkerGrid() { btn.textContent = worker.worker_name; btn.dataset.id = worker.worker_id; + // TBM 팀 구성에 포함된 작업자는 자동 선택 + if (tbmWorkerIds.includes(worker.worker_id)) { + btn.classList.add('selected'); + selectedWorkers.add(worker.worker_id); + } + btn.addEventListener('click', () => { toggleWorkerSelection(worker.worker_id, btn); }); grid.appendChild(btn); }); + + // 자동 선택된 작업자가 있으면 다음 단계 버튼 활성화 + const nextBtn = document.getElementById('nextStep2'); + if (nextBtn) { + nextBtn.disabled = selectedWorkers.size === 0; + } } // 작업자 선택 토글 diff --git a/web-ui/js/tbm.js b/web-ui/js/tbm.js index 9c018ad..5f5ad92 100644 --- a/web-ui/js/tbm.js +++ b/web-ui/js/tbm.js @@ -176,14 +176,17 @@ function displayTbmSessions() { ` : ''} -
+
${session.status === 'draft' ? ` - - + @@ -587,12 +590,261 @@ async function completeTbmSession() { window.completeTbmSession = completeTbmSession; // TBM 세션 상세 보기 -function viewTbmSession(sessionId) { - // TODO: 상세 보기 페이지 또는 모달 구현 - console.log('TBM 세션 상세 보기:', sessionId); +async function viewTbmSession(sessionId) { + try { + // 세션 정보, 팀 구성, 안전 체크 동시 조회 + const [sessionRes, teamRes, safetyRes] = await Promise.all([ + window.apiCall(`/tbm/sessions/${sessionId}`), + window.apiCall(`/tbm/sessions/${sessionId}/team`), + window.apiCall(`/tbm/sessions/${sessionId}/safety`) + ]); + + const session = sessionRes?.data; + const team = teamRes?.data || []; + const safety = safetyRes?.data || []; + + if (!session) { + showToast('세션 정보를 불러올 수 없습니다.', 'error'); + return; + } + + // 기본 정보 표시 + const basicInfo = document.getElementById('detailBasicInfo'); + basicInfo.innerHTML = ` +
+
팀장
+
${session.leader_name}
+
+
+
날짜
+
${session.session_date}
+
+
+
프로젝트
+
${session.project_name || '-'}
+
+
+
작업 장소
+
${session.work_location || '-'}
+
+
+
작업 내용
+
${session.work_description || '-'}
+
+ ${session.safety_notes ? ` +
+
⚠️ 안전 특이사항
+
${session.safety_notes}
+
+ ` : ''} + `; + + // 팀 구성 표시 + const teamMembers = document.getElementById('detailTeamMembers'); + if (team.length === 0) { + teamMembers.innerHTML = '

등록된 팀원이 없습니다.

'; + } else { + teamMembers.innerHTML = team.map(member => ` +
+
${member.worker_name}
+
${member.job_type || ''}
+ ${member.is_present ? '' : '
결석
'} +
+ `).join(''); + } + + // 안전 체크 표시 + const safetyChecks = document.getElementById('detailSafetyChecks'); + if (safety.length === 0) { + safetyChecks.innerHTML = '

안전 체크 기록이 없습니다.

'; + } else { + // 카테고리별 그룹화 + const grouped = {}; + safety.forEach(check => { + if (!grouped[check.check_category]) { + grouped[check.check_category] = []; + } + grouped[check.check_category].push(check); + }); + + const categoryNames = { + 'PPE': '개인 보호 장비', + 'EQUIPMENT': '장비 점검', + 'ENVIRONMENT': '작업 환경', + 'EMERGENCY': '비상 대응' + }; + + safetyChecks.innerHTML = Object.keys(grouped).map(category => ` +
+
+ ${categoryNames[category] || category} +
+
+ ${grouped[category].map(check => ` +
+ ${check.is_checked ? '✅' : '❌'} + ${check.check_item} +
+ `).join('')} +
+
+ `).join(''); + } + + document.getElementById('detailModal').style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + } catch (error) { + console.error('❌ TBM 상세 조회 오류:', error); + showToast('상세 정보를 불러오는 중 오류가 발생했습니다.', 'error'); + } } window.viewTbmSession = viewTbmSession; +// 상세보기 모달 닫기 +function closeDetailModal() { + document.getElementById('detailModal').style.display = 'none'; + document.body.style.overflow = 'auto'; +} +window.closeDetailModal = closeDetailModal; + +// 작업 인계 모달 열기 +async function openHandoverModal(sessionId) { + currentSessionId = sessionId; + + // 세션 정보와 팀 구성 조회 + try { + const [sessionRes, teamRes] = await Promise.all([ + window.apiCall(`/tbm/sessions/${sessionId}`), + window.apiCall(`/tbm/sessions/${sessionId}/team`) + ]); + + const session = sessionRes?.data; + const team = teamRes?.data || []; + + if (!session) { + showToast('세션 정보를 불러올 수 없습니다.', 'error'); + return; + } + + // 현재 세션의 팀장을 제외한 리더 목록 + const toLeaderSelect = document.getElementById('toLeaderId'); + const otherLeaders = allWorkers.filter(w => + (w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin') && + w.worker_id !== session.leader_id + ); + + toLeaderSelect.innerHTML = '' + + otherLeaders.map(w => ` + + `).join(''); + + // 인계할 팀원 목록 + const handoverTeamList = document.getElementById('handoverTeamList'); + if (team.length === 0) { + handoverTeamList.innerHTML = '

팀 구성이 없습니다.

'; + } else { + handoverTeamList.innerHTML = team.map(member => ` + + `).join(''); + } + + // 기본값 설정 + document.getElementById('handoverSessionId').value = sessionId; + const today = new Date().toISOString().split('T')[0]; + const now = new Date().toTimeString().slice(0, 5); + document.getElementById('handoverDate').value = today; + document.getElementById('handoverTime').value = now; + document.getElementById('handoverReason').value = ''; + document.getElementById('handoverNotes').value = ''; + + document.getElementById('handoverModal').style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + } catch (error) { + console.error('❌ 인계 모달 열기 오류:', error); + showToast('인계 정보를 불러오는 중 오류가 발생했습니다.', 'error'); + } +} +window.openHandoverModal = openHandoverModal; + +// 인계 모달 닫기 +function closeHandoverModal() { + document.getElementById('handoverModal').style.display = 'none'; + document.body.style.overflow = 'auto'; +} +window.closeHandoverModal = closeHandoverModal; + +// 작업 인계 저장 +async function saveHandover() { + const sessionId = currentSessionId; + const toLeaderId = parseInt(document.getElementById('toLeaderId').value); + const reason = document.getElementById('handoverReason').value; + const handoverDate = document.getElementById('handoverDate').value; + const handoverTime = document.getElementById('handoverTime').value; + const handoverNotes = document.getElementById('handoverNotes').value; + + if (!toLeaderId || !reason || !handoverDate) { + showToast('필수 항목을 입력해주세요.', 'error'); + return; + } + + // 인계할 작업자 목록 + const workerIds = []; + document.querySelectorAll('.handover-worker-checkbox:checked').forEach(cb => { + workerIds.push(parseInt(cb.value)); + }); + + if (workerIds.length === 0) { + showToast('인계할 팀원을 최소 1명 이상 선택해주세요.', 'error'); + return; + } + + try { + // 세션 정보 조회 (from_leader_id 가져오기) + const sessionRes = await window.apiCall(`/tbm/sessions/${sessionId}`); + const fromLeaderId = sessionRes?.data?.leader_id; + + if (!fromLeaderId) { + showToast('세션 정보를 찾을 수 없습니다.', 'error'); + return; + } + + const handoverData = { + session_id: sessionId, + from_leader_id: fromLeaderId, + to_leader_id: toLeaderId, + handover_date: handoverDate, + handover_time: handoverTime, + reason: reason, + handover_notes: handoverNotes, + worker_ids: workerIds + }; + + const response = await window.apiCall('/tbm/handovers', 'POST', handoverData); + + if (response && response.success) { + showToast('작업 인계가 요청되었습니다.', 'success'); + closeHandoverModal(); + } else { + throw new Error(response.message || '인계 요청에 실패했습니다.'); + } + } catch (error) { + console.error('❌ 작업 인계 저장 오류:', error); + showToast('작업 인계 중 오류가 발생했습니다.', 'error'); + } +} +window.saveHandover = saveHandover; + // 토스트 알림 function showToast(message, type = 'info', duration = 3000) { const container = document.getElementById('toastContainer'); diff --git a/web-ui/pages/work/tbm.html b/web-ui/pages/work/tbm.html index 97ae32d..c458644 100644 --- a/web-ui/pages/work/tbm.html +++ b/web-ui/pages/work/tbm.html @@ -226,11 +226,115 @@
+ + + + + +
- +