## Backend Changes - Create tasks table with work_type_id FK to work_types - Add taskModel, taskController, taskRoutes for task CRUD - Update tbmModel to support work_type_id and task_id - Add migrations for tasks table and TBM integration ## Frontend Changes - Create task management admin page (tasks.html, task-management.js) - Update TBM modal to include work type (공정) and task (작업) selection - Add cascading dropdown: work type → task selection - Display work type and task info in TBM session cards - Update sidebar navigation in all admin pages ## Database Schema - tasks: task_id, work_type_id, task_name, description, is_active - tbm_sessions: add work_type_id, task_id columns with FKs - Foreign keys maintain referential integrity with work_types and tasks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1105 lines
38 KiB
JavaScript
1105 lines
38 KiB
JavaScript
// tbm.js - TBM 관리 페이지 JavaScript
|
||
|
||
// 전역 변수
|
||
let allSessions = [];
|
||
let todaySessions = [];
|
||
let allWorkers = [];
|
||
let allProjects = [];
|
||
let allWorkTypes = [];
|
||
let allTasks = [];
|
||
let allSafetyChecks = [];
|
||
let currentSessionId = null;
|
||
let selectedWorkers = new Set();
|
||
let currentTab = 'tbm-input';
|
||
|
||
// 페이지 초기화
|
||
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 loadTodayOnlyTbm();
|
||
});
|
||
|
||
// 이벤트 리스너 설정
|
||
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 + '개');
|
||
}
|
||
|
||
// 공정(Work Types) 목록 로드
|
||
const workTypesResponse = await window.apiCall('/tools/work-types');
|
||
if (workTypesResponse && workTypesResponse.success) {
|
||
allWorkTypes = workTypesResponse.data || [];
|
||
console.log('✅ 공정 목록 로드:', allWorkTypes.length + '개');
|
||
}
|
||
|
||
// 작업(Tasks) 목록 로드
|
||
const tasksResponse = await window.apiCall('/tasks/active/list');
|
||
if (tasksResponse && tasksResponse.success) {
|
||
allTasks = tasksResponse.data || [];
|
||
console.log('✅ 작업 목록 로드:', allTasks.length + '개');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ 초기 데이터 로드 오류:', error);
|
||
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 탭 전환 ====================
|
||
|
||
// 탭 전환
|
||
function switchTbmTab(tabName) {
|
||
currentTab = tabName;
|
||
|
||
// 탭 버튼 활성화 상태 변경
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
if (btn.dataset.tab === tabName) {
|
||
btn.classList.add('active');
|
||
} else {
|
||
btn.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// 탭 컨텐츠 표시 변경
|
||
document.querySelectorAll('.code-tab-content').forEach(content => {
|
||
content.classList.remove('active');
|
||
});
|
||
document.getElementById(`${tabName}-tab`).classList.add('active');
|
||
|
||
// 탭에 따라 데이터 로드
|
||
if (tabName === 'tbm-input') {
|
||
loadTodayOnlyTbm();
|
||
} else if (tabName === 'tbm-manage') {
|
||
const tbmDate = document.getElementById('tbmDate');
|
||
if (tbmDate && tbmDate.value) {
|
||
loadTbmSessionsByDate(tbmDate.value);
|
||
} else {
|
||
loadTodayTbm();
|
||
}
|
||
}
|
||
}
|
||
window.switchTbmTab = switchTbmTab;
|
||
|
||
// ==================== TBM 입력 탭 ====================
|
||
|
||
// 오늘의 TBM만 로드 (TBM 입력 탭용)
|
||
async function loadTodayOnlyTbm() {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
try {
|
||
const response = await window.apiCall(`/tbm/sessions/date/${today}`);
|
||
|
||
if (response && response.success) {
|
||
todaySessions = response.data || [];
|
||
displayTodayTbmSessions();
|
||
} else {
|
||
todaySessions = [];
|
||
displayTodayTbmSessions();
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 오늘 TBM 조회 오류:', error);
|
||
showToast('오늘 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
|
||
todaySessions = [];
|
||
displayTodayTbmSessions();
|
||
}
|
||
}
|
||
window.loadTodayOnlyTbm = loadTodayOnlyTbm;
|
||
|
||
// 오늘의 TBM 세션 표시
|
||
function displayTodayTbmSessions() {
|
||
const grid = document.getElementById('todayTbmGrid');
|
||
const emptyState = document.getElementById('todayEmptyState');
|
||
const todayTotalEl = document.getElementById('todayTotalSessions');
|
||
const todayCompletedEl = document.getElementById('todayCompletedSessions');
|
||
const todayActiveEl = document.getElementById('todayActiveSessions');
|
||
|
||
if (todaySessions.length === 0) {
|
||
grid.innerHTML = '';
|
||
emptyState.style.display = 'flex';
|
||
todayTotalEl.textContent = '0';
|
||
todayCompletedEl.textContent = '0';
|
||
todayActiveEl.textContent = '0';
|
||
return;
|
||
}
|
||
|
||
emptyState.style.display = 'none';
|
||
|
||
const completedCount = todaySessions.filter(s => s.status === 'completed').length;
|
||
const activeCount = todaySessions.filter(s => s.status === 'draft').length;
|
||
|
||
todayTotalEl.textContent = todaySessions.length;
|
||
todayCompletedEl.textContent = completedCount;
|
||
todayActiveEl.textContent = activeCount;
|
||
|
||
grid.innerHTML = todaySessions.map(session => createSessionCard(session)).join('');
|
||
}
|
||
|
||
// ==================== TBM 관리 탭 ====================
|
||
|
||
// 오늘 TBM 로드 (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 loadAllTbm() {
|
||
try {
|
||
const response = await window.apiCall('/tbm/sessions');
|
||
|
||
if (response && response.success) {
|
||
allSessions = response.data || [];
|
||
document.getElementById('tbmDate').value = '';
|
||
displayTbmSessions();
|
||
} else {
|
||
allSessions = [];
|
||
displayTbmSessions();
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 전체 TBM 조회 오류:', error);
|
||
showToast('전체 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
|
||
allSessions = [];
|
||
displayTbmSessions();
|
||
}
|
||
}
|
||
window.loadAllTbm = loadAllTbm;
|
||
|
||
// 특정 날짜의 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 => createSessionCard(session)).join('');
|
||
}
|
||
|
||
// TBM 세션 카드 생성 (공통)
|
||
function createSessionCard(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.session_date} | ${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_type_name || '-'}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">작업</span>
|
||
<span class="info-value">${session.task_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; flex-wrap: wrap;">
|
||
${session.status === 'draft' ? `
|
||
<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})" style="flex: 1; min-width: 100px;">
|
||
👥 팀 구성
|
||
</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})" style="flex: 1; min-width: 100px;">
|
||
✅ 안전 체크
|
||
</button>
|
||
<button class="btn btn-sm" style="background: #f59e0b; color: white; border: none;" onclick="event.stopPropagation(); openHandoverModal(${session.session_id})">
|
||
📤 인계
|
||
</button>
|
||
<button class="btn btn-sm btn-success" onclick="event.stopPropagation(); openCompleteTbmModal(${session.session_id})">
|
||
완료
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 새 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();
|
||
populateWorkTypeSelect();
|
||
|
||
// 작업 드롭다운 초기화
|
||
const taskSelect = document.getElementById('taskId');
|
||
if (taskSelect) {
|
||
taskSelect.innerHTML = '<option value="">작업 선택...</option>';
|
||
taskSelect.disabled = true;
|
||
}
|
||
|
||
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('');
|
||
}
|
||
|
||
// 공정(Work Type) 선택 드롭다운 채우기
|
||
function populateWorkTypeSelect() {
|
||
const workTypeSelect = document.getElementById('workTypeId');
|
||
if (!workTypeSelect) return;
|
||
|
||
workTypeSelect.innerHTML = '<option value="">공정 선택...</option>' +
|
||
allWorkTypes.map(wt => `
|
||
<option value="${wt.id}">${wt.name}${wt.category ? ' (' + wt.category + ')' : ''}</option>
|
||
`).join('');
|
||
}
|
||
|
||
// 작업(Task) 선택 드롭다운 채우기 (공정 선택 시 호출)
|
||
function loadTasksByWorkType() {
|
||
const workTypeId = document.getElementById('workTypeId').value;
|
||
const taskSelect = document.getElementById('taskId');
|
||
|
||
if (!taskSelect) return;
|
||
|
||
if (!workTypeId) {
|
||
taskSelect.innerHTML = '<option value="">작업 선택...</option>';
|
||
taskSelect.disabled = true;
|
||
return;
|
||
}
|
||
|
||
// 선택한 공정에 해당하는 작업만 필터링
|
||
const filteredTasks = allTasks.filter(task =>
|
||
task.work_type_id === parseInt(workTypeId)
|
||
);
|
||
|
||
taskSelect.disabled = false;
|
||
taskSelect.innerHTML = '<option value="">작업 선택...</option>' +
|
||
filteredTasks.map(task => `
|
||
<option value="${task.task_id}">${task.task_name}</option>
|
||
`).join('');
|
||
|
||
if (filteredTasks.length === 0) {
|
||
taskSelect.innerHTML = '<option value="">등록된 작업이 없습니다</option>';
|
||
taskSelect.disabled = true;
|
||
}
|
||
}
|
||
window.loadTasksByWorkType = loadTasksByWorkType;
|
||
|
||
// TBM 모달 닫기
|
||
function closeTbmModal() {
|
||
document.getElementById('tbmModal').style.display = 'none';
|
||
document.body.style.overflow = 'auto';
|
||
}
|
||
window.closeTbmModal = closeTbmModal;
|
||
|
||
// TBM 세션 저장
|
||
async function saveTbmSession() {
|
||
const workTypeId = document.getElementById('workTypeId').value;
|
||
const taskId = document.getElementById('taskId').value;
|
||
|
||
const sessionData = {
|
||
session_date: document.getElementById('sessionDate').value,
|
||
leader_id: parseInt(document.getElementById('leaderId').value),
|
||
project_id: document.getElementById('projectId').value || null,
|
||
work_type_id: workTypeId ? parseInt(workTypeId) : null,
|
||
task_id: taskId ? parseInt(taskId) : 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;
|
||
}
|
||
|
||
if (!sessionData.work_type_id || !sessionData.task_id) {
|
||
showToast('공정과 작업을 선택해주세요.', '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;
|
||
|
||
// 목록 새로고침
|
||
if (currentTab === 'tbm-input') {
|
||
await loadTodayOnlyTbm();
|
||
} else {
|
||
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();
|
||
|
||
// 목록 새로고침
|
||
if (currentTab === 'tbm-input') {
|
||
await loadTodayOnlyTbm();
|
||
} else {
|
||
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();
|
||
|
||
// 목록 새로고침
|
||
if (currentTab === 'tbm-input') {
|
||
await loadTodayOnlyTbm();
|
||
} else {
|
||
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 세션 상세 보기
|
||
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 = `
|
||
<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;">${session.leader_name}</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;">${session.session_date}</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;">${session.project_name || '-'}</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;">${session.work_location || '-'}</div>
|
||
</div>
|
||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; grid-column: span 2;">
|
||
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 내용</div>
|
||
<div style="color: #111827;">${session.work_description || '-'}</div>
|
||
</div>
|
||
${session.safety_notes ? `
|
||
<div style="padding: 0.75rem; background: #fef3c7; border-radius: 0.375rem; grid-column: span 2;">
|
||
<div style="font-size: 0.75rem; color: #92400e; margin-bottom: 0.25rem;">⚠️ 안전 특이사항</div>
|
||
<div style="color: #78350f;">${session.safety_notes}</div>
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
|
||
// 팀 구성 표시
|
||
const teamMembers = document.getElementById('detailTeamMembers');
|
||
if (team.length === 0) {
|
||
teamMembers.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">등록된 팀원이 없습니다.</p>';
|
||
} else {
|
||
teamMembers.innerHTML = team.map(member => `
|
||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
|
||
<div style="font-weight: 600; color: #111827; margin-bottom: 0.25rem;">${member.worker_name}</div>
|
||
<div style="font-size: 0.75rem; color: #6b7280;">${member.job_type || ''}</div>
|
||
${member.is_present ? '' : '<div style="font-size: 0.75rem; color: #ef4444; margin-top: 0.25rem;">결석</div>'}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// 안전 체크 표시
|
||
const safetyChecks = document.getElementById('detailSafetyChecks');
|
||
if (safety.length === 0) {
|
||
safetyChecks.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">안전 체크 기록이 없습니다.</p>';
|
||
} 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 => `
|
||
<div style="margin-bottom: 1rem;">
|
||
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; margin-bottom: 0.5rem; padding: 0.5rem; background: #f3f4f6; border-radius: 0.25rem;">
|
||
${categoryNames[category] || category}
|
||
</div>
|
||
<div style="display: grid; gap: 0.5rem;">
|
||
${grouped[category].map(check => `
|
||
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-radius: 0.25rem; ${check.is_checked ? 'background: #f0fdf4;' : 'background: #fef2f2;'}">
|
||
<span style="font-size: 1.25rem;">${check.is_checked ? '✅' : '❌'}</span>
|
||
<span style="flex: 1; font-size: 0.875rem; color: #374151;">${check.check_item}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`).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 = '<option value="">인수자 선택...</option>' +
|
||
otherLeaders.map(w => `
|
||
<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</option>
|
||
`).join('');
|
||
|
||
// 인계할 팀원 목록
|
||
const handoverTeamList = document.getElementById('handoverTeamList');
|
||
if (team.length === 0) {
|
||
handoverTeamList.innerHTML = '<p style="padding: 1rem; color: #6b7280; text-align: center;">팀 구성이 없습니다.</p>';
|
||
} else {
|
||
handoverTeamList.innerHTML = team.map(member => `
|
||
<label style="display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; cursor: pointer; border-radius: 0.25rem; transition: background 0.2s;"
|
||
onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background='white'">
|
||
<input type="checkbox"
|
||
class="handover-worker-checkbox"
|
||
value="${member.worker_id}"
|
||
checked
|
||
style="width: 16px; height: 16px; cursor: pointer;">
|
||
<span style="font-weight: 500; font-size: 0.875rem;">${member.worker_name}</span>
|
||
<span style="font-size: 0.75rem; color: #6b7280; margin-left: auto;">${member.job_type || ''}</span>
|
||
</label>
|
||
`).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');
|
||
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);
|
||
}
|