feat: TBM JavaScript 로직 구현 완료

## 주요 기능

### 1. TBM 세션 관리
- 날짜별 TBM 세션 목록 조회
- 새 TBM 세션 생성
- TBM 세션 완료 처리
- 세션 상태별 표시 (진행중/완료/취소)

### 2. 팀 구성 관리
- 작업자 선택 그리드 UI
- 전체 선택/해제 기능
- 선택된 작업자 실시간 표시
- 팀원 일괄 추가

### 3. 안전 체크리스트
- 카테고리별 체크리스트 표시
  - PPE (개인 보호 장비)
  - EQUIPMENT (장비 점검)
  - ENVIRONMENT (작업 환경)
  - EMERGENCY (비상 대응)
- 필수/선택 항목 구분
- 체크 상태 저장

### 4. UI/UX
- 모달 기반 인터페이스
- 토스트 알림
- 실시간 통계 표시 (총 세션, 완료 세션)
- 반응형 그리드 레이아웃

## 구현 상세

### 전역 상태 관리
- allSessions: TBM 세션 목록
- allWorkers: 작업자 목록
- allProjects: 프로젝트 목록
- allSafetyChecks: 안전 체크리스트
- selectedWorkers: 선택된 작업자 (Set)

### API 연동
- GET /api/tbm/sessions/date/:date
- POST /api/tbm/sessions
- POST /api/tbm/sessions/:id/team/batch
- GET /api/tbm/sessions/:id/safety
- POST /api/tbm/sessions/:id/safety
- POST /api/tbm/sessions/:id/complete

### 주요 함수
- loadTbmSessionsByDate(): 날짜별 세션 조회
- saveTbmSession(): TBM 세션 생성
- saveTeamComposition(): 팀 구성 저장
- saveSafetyChecklist(): 안전 체크 저장
- completeTbmSession(): TBM 완료 처리

## 파일
- web-ui/js/tbm.js (신규, 약 600줄)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-20 15:41:23 +09:00
parent 4d0c4c0801
commit f8138685a1

638
web-ui/js/tbm.js Normal file
View File

@@ -0,0 +1,638 @@
// tbm.js - TBM 관리 페이지 JavaScript
// 전역 변수
let allSessions = [];
let allWorkers = [];
let allProjects = [];
let allSafetyChecks = [];
let currentSessionId = null;
let selectedWorkers = new Set();
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
console.log('🛠️ TBM 관리 페이지 초기화');
// API 함수가 로드될 때까지 대기
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
showToast('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
// 오늘 날짜 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('tbmDate').value = today;
document.getElementById('sessionDate').value = today;
// 이벤트 리스너 설정
setupEventListeners();
// 초기 데이터 로드
await loadInitialData();
await loadTodayTbm();
});
// 이벤트 리스너 설정
function setupEventListeners() {
const tbmDateInput = document.getElementById('tbmDate');
if (tbmDateInput) {
tbmDateInput.addEventListener('change', () => {
const date = tbmDateInput.value;
loadTbmSessionsByDate(date);
});
}
}
// 초기 데이터 로드
async function loadInitialData() {
try {
// 작업자 목록 로드
const workersResponse = await window.apiCall('/workers?limit=1000');
if (workersResponse) {
allWorkers = Array.isArray(workersResponse) ? workersResponse : (workersResponse.data || []);
// 활성 상태인 작업자만 필터링
allWorkers = allWorkers.filter(w => w.status === 'active' && w.employment_status === 'employed');
console.log('✅ 작업자 목록 로드:', allWorkers.length + '명');
}
// 프로젝트 목록 로드
const projectsResponse = await window.apiCall('/projects?is_active=1');
if (projectsResponse) {
allProjects = Array.isArray(projectsResponse) ? projectsResponse : (projectsResponse.data || []);
console.log('✅ 프로젝트 목록 로드:', allProjects.length + '개');
populateProjectSelect();
}
// 안전 체크리스트 로드
const safetyResponse = await window.apiCall('/tbm/safety-checks');
if (safetyResponse && safetyResponse.success) {
allSafetyChecks = safetyResponse.data;
console.log('✅ 안전 체크리스트 로드:', allSafetyChecks.length + '개');
}
} catch (error) {
console.error('❌ 초기 데이터 로드 오류:', error);
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
// 오늘 TBM 로드
async function loadTodayTbm() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('tbmDate').value = today;
await loadTbmSessionsByDate(today);
}
window.loadTodayTbm = loadTodayTbm;
// 특정 날짜의 TBM 세션 목록 로드
async function loadTbmSessionsByDate(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
if (response && response.success) {
allSessions = response.data || [];
displayTbmSessions();
} else {
allSessions = [];
displayTbmSessions();
}
} catch (error) {
console.error('❌ TBM 세션 조회 오류:', error);
showToast('TBM 세션을 불러오는 중 오류가 발생했습니다.', 'error');
allSessions = [];
displayTbmSessions();
}
}
// TBM 세션 목록 표시
function displayTbmSessions() {
const grid = document.getElementById('tbmSessionsGrid');
const emptyState = document.getElementById('emptyState');
const totalSessionsEl = document.getElementById('totalSessions');
const completedSessionsEl = document.getElementById('completedSessions');
if (allSessions.length === 0) {
grid.innerHTML = '';
emptyState.style.display = 'flex';
totalSessionsEl.textContent = '0';
completedSessionsEl.textContent = '0';
return;
}
emptyState.style.display = 'none';
const completedCount = allSessions.filter(s => s.status === 'completed').length;
totalSessionsEl.textContent = allSessions.length;
completedSessionsEl.textContent = completedCount;
grid.innerHTML = allSessions.map(session => {
const statusBadge = {
'draft': '<span class="badge" style="background: #fef3c7; color: #92400e;">진행중</span>',
'completed': '<span class="badge" style="background: #dcfce7; color: #166534;">완료</span>',
'cancelled': '<span class="badge" style="background: #fee2e2; color: #991b1b;">취소</span>'
}[session.status] || '';
return `
<div class="project-card" style="cursor: pointer;" onclick="viewTbmSession(${session.session_id})">
<div class="project-header">
<div>
<h3 class="project-name" style="font-size: 1rem; margin-bottom: 0.25rem;">
${session.leader_name || '팀장 미지정'}
</h3>
<p style="font-size: 0.75rem; color: #6b7280; margin: 0;">
${session.leader_job_type || ''}
</p>
</div>
${statusBadge}
</div>
<div class="project-info" style="margin-top: 1rem;">
<div class="info-item">
<span class="info-label">프로젝트</span>
<span class="info-value">${session.project_name || '-'}</span>
</div>
<div class="info-item">
<span class="info-label">작업 장소</span>
<span class="info-value">${session.work_location || '-'}</span>
</div>
<div class="info-item">
<span class="info-label">팀원 수</span>
<span class="info-value">${session.team_member_count || 0}명</span>
</div>
<div class="info-item">
<span class="info-label">시작 시간</span>
<span class="info-value">${session.start_time || '-'}</span>
</div>
</div>
${session.work_description ? `
<div style="margin-top: 0.75rem; padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; font-size: 0.875rem; color: #374151;">
${session.work_description}
</div>
` : ''}
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
${session.status === 'draft' ? `
<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})" style="flex: 1;">
👥 팀 구성
</button>
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})" style="flex: 1;">
✅ 안전 체크
</button>
<button class="btn btn-sm btn-success" onclick="event.stopPropagation(); openCompleteTbmModal(${session.session_id})">
완료
</button>
` : ''}
</div>
</div>
`;
}).join('');
}
// 새 TBM 모달 열기
function openNewTbmModal() {
currentSessionId = null;
document.getElementById('modalTitle').textContent = '새 TBM 시작';
document.getElementById('sessionId').value = '';
document.getElementById('tbmForm').reset();
const today = new Date().toISOString().split('T')[0];
document.getElementById('sessionDate').value = today;
// 팀장 목록 로드
populateLeaderSelect();
populateProjectSelect();
document.getElementById('tbmModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
window.openNewTbmModal = openNewTbmModal;
// 팀장 선택 드롭다운 채우기
function populateLeaderSelect() {
const leaderSelect = document.getElementById('leaderId');
if (!leaderSelect) return;
const leaders = allWorkers.filter(w =>
w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin'
);
leaderSelect.innerHTML = '<option value="">팀장 선택...</option>' +
leaders.map(w => `
<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</option>
`).join('');
}
// 프로젝트 선택 드롭다운 채우기
function populateProjectSelect() {
const projectSelect = document.getElementById('projectId');
if (!projectSelect) return;
projectSelect.innerHTML = '<option value="">프로젝트 선택...</option>' +
allProjects.map(p => `
<option value="${p.project_id}">${p.project_name} (${p.job_no})</option>
`).join('');
}
// TBM 모달 닫기
function closeTbmModal() {
document.getElementById('tbmModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeTbmModal = closeTbmModal;
// TBM 세션 저장
async function saveTbmSession() {
const sessionData = {
session_date: document.getElementById('sessionDate').value,
leader_id: parseInt(document.getElementById('leaderId').value),
project_id: document.getElementById('projectId').value || null,
work_location: document.getElementById('workLocation').value || null,
work_description: document.getElementById('workDescription').value || null,
safety_notes: document.getElementById('safetyNotes').value || null,
start_time: document.getElementById('startTime').value || null
};
if (!sessionData.session_date || !sessionData.leader_id) {
showToast('TBM 날짜와 팀장을 선택해주세요.', 'error');
return;
}
try {
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (response && response.success) {
showToast('TBM 세션이 생성되었습니다.', 'success');
closeTbmModal();
const createdSessionId = response.data.session_id;
// 목록 새로고침
await loadTbmSessionsByDate(sessionData.session_date);
// 팀 구성 모달 열기
setTimeout(() => {
openTeamCompositionModal(createdSessionId);
}, 500);
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('❌ TBM 세션 저장 오류:', error);
showToast('TBM 세션 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveTbmSession = saveTbmSession;
// 팀 구성 모달 열기
async function openTeamCompositionModal(sessionId) {
currentSessionId = sessionId;
selectedWorkers.clear();
// 기존 팀 구성 로드
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
if (response && response.success) {
response.data.forEach(member => {
selectedWorkers.add(member.worker_id);
});
}
} catch (error) {
console.error('팀 구성 조회 오류:', error);
}
// 작업자 선택 그리드 생성
const grid = document.getElementById('workerSelectionGrid');
grid.innerHTML = allWorkers.map(worker => `
<label style="display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border: 1px solid #e5e7eb; border-radius: 0.375rem; cursor: pointer; transition: all 0.2s;"
onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background='white'">
<input type="checkbox"
class="worker-checkbox"
data-worker-id="${worker.worker_id}"
data-worker-name="${worker.worker_name}"
${selectedWorkers.has(worker.worker_id) ? 'checked' : ''}
onchange="updateSelectedWorkers()"
style="width: 16px; height: 16px; cursor: pointer;">
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.875rem;">${worker.worker_name}</div>
<div style="font-size: 0.75rem; color: #6b7280;">${worker.job_type || ''}</div>
</div>
</label>
`).join('');
updateSelectedWorkers();
document.getElementById('teamModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
window.openTeamCompositionModal = openTeamCompositionModal;
// 선택된 작업자 업데이트
function updateSelectedWorkers() {
selectedWorkers.clear();
document.querySelectorAll('.worker-checkbox:checked').forEach(cb => {
selectedWorkers.add(parseInt(cb.dataset.workerId));
});
const selectedCount = document.getElementById('selectedCount');
const selectedList = document.getElementById('selectedWorkersList');
selectedCount.textContent = selectedWorkers.size;
if (selectedWorkers.size === 0) {
selectedList.innerHTML = '<p style="margin: 0; color: #9ca3af; font-size: 0.875rem;">작업자를 선택해주세요</p>';
} else {
const selectedWorkersArray = Array.from(selectedWorkers).map(id => {
const worker = allWorkers.find(w => w.worker_id === id);
return worker ? `
<span style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.75rem; background: #3b82f6; color: white; border-radius: 9999px; font-size: 0.875rem;">
${worker.worker_name}
<button onclick="removeWorker(${id})" style="background: none; border: none; color: white; cursor: pointer; padding: 0; margin-left: 0.25rem; font-size: 1rem; line-height: 1;">×</button>
</span>
` : '';
});
selectedList.innerHTML = selectedWorkersArray.join('');
}
}
window.updateSelectedWorkers = updateSelectedWorkers;
// 작업자 제거
function removeWorker(workerId) {
const checkbox = document.querySelector(`.worker-checkbox[data-worker-id="${workerId}"]`);
if (checkbox) {
checkbox.checked = false;
updateSelectedWorkers();
}
}
window.removeWorker = removeWorker;
// 전체 선택
function selectAllWorkers() {
document.querySelectorAll('.worker-checkbox').forEach(cb => {
cb.checked = true;
});
updateSelectedWorkers();
}
window.selectAllWorkers = selectAllWorkers;
// 전체 해제
function deselectAllWorkers() {
document.querySelectorAll('.worker-checkbox').forEach(cb => {
cb.checked = false;
});
updateSelectedWorkers();
}
window.deselectAllWorkers = deselectAllWorkers;
// 팀 구성 모달 닫기
function closeTeamModal() {
document.getElementById('teamModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeTeamModal = closeTeamModal;
// 팀 구성 저장
async function saveTeamComposition() {
if (selectedWorkers.size === 0) {
showToast('최소 1명 이상의 작업자를 선택해주세요.', 'error');
return;
}
const members = Array.from(selectedWorkers).map(workerId => ({
worker_id: workerId
}));
try {
const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/team/batch`,
'POST',
{ members }
);
if (response && response.success) {
showToast(`${selectedWorkers.size}명의 팀원이 추가되었습니다.`, 'success');
closeTeamModal();
// 목록 새로고침
const date = document.getElementById('tbmDate').value;
await loadTbmSessionsByDate(date);
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('❌ 팀 구성 저장 오류:', error);
showToast('팀 구성 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveTeamComposition = saveTeamComposition;
// 안전 체크 모달 열기
async function openSafetyCheckModal(sessionId) {
currentSessionId = sessionId;
// 기존 안전 체크 기록 로드
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`);
const existingRecords = response && response.success ? response.data : [];
// 카테고리별로 그룹화
const grouped = {};
allSafetyChecks.forEach(check => {
if (!grouped[check.check_category]) {
grouped[check.check_category] = [];
}
const existingRecord = existingRecords.find(r => r.check_id === check.check_id);
grouped[check.check_category].push({
...check,
is_checked: existingRecord ? existingRecord.is_checked : false,
notes: existingRecord ? existingRecord.notes : ''
});
});
const categoryNames = {
'PPE': '개인 보호 장비',
'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경',
'EMERGENCY': '비상 대응'
};
const container = document.getElementById('safetyChecklistContainer');
container.innerHTML = Object.keys(grouped).map(category => `
<div style="margin-bottom: 1.5rem;">
<div style="font-weight: 600; font-size: 0.9375rem; color: #374151; padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; margin-bottom: 0.5rem;">
${categoryNames[category] || category}
</div>
${grouped[category].map(check => `
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6;">
<label style="display: flex; align-items: start; gap: 0.75rem; cursor: pointer;">
<input type="checkbox"
class="safety-check"
data-check-id="${check.check_id}"
${check.is_checked ? 'checked' : ''}
${check.is_required ? 'required' : ''}
style="width: 18px; height: 18px; margin-top: 0.125rem; cursor: pointer;">
<div style="flex: 1;">
<div style="font-weight: 500; color: #111827;">
${check.check_item}
${check.is_required ? '<span style="color: #ef4444;">*</span>' : ''}
</div>
${check.description ? `<div style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">${check.description}</div>` : ''}
</div>
</label>
</div>
`).join('')}
</div>
`).join('');
document.getElementById('safetyModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
} catch (error) {
console.error('❌ 안전 체크 조회 오류:', error);
showToast('안전 체크 정보를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.openSafetyCheckModal = openSafetyCheckModal;
// 안전 체크 모달 닫기
function closeSafetyModal() {
document.getElementById('safetyModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeSafetyModal = closeSafetyModal;
// 안전 체크리스트 저장
async function saveSafetyChecklist() {
const records = [];
document.querySelectorAll('.safety-check').forEach(cb => {
records.push({
check_id: parseInt(cb.dataset.checkId),
is_checked: cb.checked
});
});
try {
const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/safety`,
'POST',
{ records }
);
if (response && response.success) {
showToast('안전 체크가 완료되었습니다.', 'success');
closeSafetyModal();
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('❌ 안전 체크 저장 오류:', error);
showToast('안전 체크 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveSafetyChecklist = saveSafetyChecklist;
// TBM 완료 모달 열기
function openCompleteTbmModal(sessionId) {
currentSessionId = sessionId;
const now = new Date();
const timeString = now.toTimeString().slice(0, 5);
document.getElementById('endTime').value = timeString;
document.getElementById('completeModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
window.openCompleteTbmModal = openCompleteTbmModal;
// 완료 모달 닫기
function closeCompleteModal() {
document.getElementById('completeModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeCompleteModal = closeCompleteModal;
// TBM 세션 완료
async function completeTbmSession() {
const endTime = document.getElementById('endTime').value;
try {
const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/complete`,
'POST',
{ end_time: endTime }
);
if (response && response.success) {
showToast('TBM이 완료되었습니다.', 'success');
closeCompleteModal();
// 목록 새로고침
const date = document.getElementById('tbmDate').value;
await loadTbmSessionsByDate(date);
} else {
throw new Error(response.message || '완료 처리에 실패했습니다.');
}
} catch (error) {
console.error('❌ TBM 완료 처리 오류:', error);
showToast('TBM 완료 처리 중 오류가 발생했습니다.', 'error');
}
}
window.completeTbmSession = completeTbmSession;
// TBM 세션 상세 보기
function viewTbmSession(sessionId) {
// TODO: 상세 보기 페이지 또는 모달 구현
console.log('TBM 세션 상세 보기:', sessionId);
}
window.viewTbmSession = viewTbmSession;
// 토스트 알림
function showToast(message, type = 'info', duration = 3000) {
const container = document.getElementById('toastContainer');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const iconMap = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
toast.innerHTML = `
<div class="toast-icon">${iconMap[type] || ''}</div>
<div class="toast-message">${message}</div>
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
`;
toast.style.cssText = `
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
margin-bottom: 0.75rem;
min-width: 300px;
animation: slideIn 0.3s ease-out;
`;
container.appendChild(toast);
setTimeout(() => {
if (toast.parentElement) {
toast.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
}
}, duration);
}