Files
tk-factory-services/system1-factory/web/js/tbm.js
Hyungi Ahn abd7564e6b refactor: worker_id → user_id 전체 마이그레이션 (Phase 1-4)
sso_users.user_id를 단일 식별자로 통합. JWT에서 worker_id 제거,
department_id/is_production 추가. 백엔드 15개 모델, 11개 컨트롤러,
4개 서비스, 7개 라우트, 프론트엔드 32+ JS/11+ HTML 변환.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:13:10 +09:00

2980 lines
111 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tbm.js - TBM 관리 페이지 JavaScript
// 전역 변수: TbmState 프록시 사용 (state.js에서 정의)
// allSessions, todaySessions, allWorkers, allProjects, allWorkTypes, allTasks,
// allSafetyChecks, allWorkplaces, allWorkplaceCategories, currentUser,
// currentSessionId, selectedWorkers, workerTaskList, selectedWorkersInModal,
// currentEditingTaskLine, selectedCategory, selectedWorkplace, selectedCategoryName,
// selectedWorkplaceName, isBulkMode, bulkSelectedWorkers, loadedDaysCount,
// dateGroupedSessions, allLoadedSessions → window 프록시로 접근
// UI 전용 변수 (프록시 없음)
let currentTab = 'tbm-input';
// 모달 스크롤 잠금
let scrollLockY = 0;
let scrollLockCount = 0;
function lockBodyScroll() {
scrollLockCount++;
if (scrollLockCount > 1) return; // 이미 잠금 상태
scrollLockY = window.scrollY;
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
document.body.style.top = `-${scrollLockY}px`;
document.body.classList.add('tbm-modal-open');
}
function unlockBodyScroll() {
scrollLockCount--;
if (scrollLockCount > 0) return; // 아직 열린 모달 있음
scrollLockCount = 0;
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
document.body.style.top = '';
window.scrollTo(0, scrollLockY);
document.body.classList.remove('tbm-modal-open');
}
// ==================== 유틸리티 함수 (CommonUtils 위임) ====================
// getTodayKST, formatDate → window.CommonUtils 사용 (common/utils.js)
function getTodayKST() { return window.CommonUtils.getTodayKST(); }
function formatDate(d) { return window.CommonUtils.formatDate(d); }
// ==================== 페이지 초기화 ====================
// 페이지 초기화
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 = getTodayKST();
const tbmDateEl = document.getElementById('tbmDate');
const sessionDateEl = document.getElementById('sessionDate');
if (tbmDateEl) tbmDateEl.value = today;
if (sessionDateEl) sessionDateEl.value = today;
// 이벤트 리스너 설정
setupEventListeners();
// 초기 데이터 로드
await loadInitialData();
await loadTodayOnlyTbm();
});
// 이벤트 리스너 설정
function setupEventListeners() {
// 날짜 선택기 제거됨 - 날짜별 그룹 뷰 사용
}
// 초기 데이터 로드 → TbmAPI 위임
async function loadInitialData() {
await window.TbmAPI.loadInitialData();
// TbmAPI가 TbmState에 데이터를 설정 → 프록시를 통해 전역 변수로 접근 가능
// UI 드롭다운 채우기
populateProjectSelect();
}
// ==================== 탭 전환 ====================
// 탭 전환
function switchTbmTab(tabName) {
currentTab = tabName;
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
if (btn.dataset.tab === tabName) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// 탭 컨텐츠 표시 변경
document.querySelectorAll('.tbm-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') {
loadRecentTbmGroupedByDate();
}
}
window.switchTbmTab = switchTbmTab;
// ==================== TBM 입력 탭 ====================
// 오늘의 TBM만 로드 → TbmAPI 위임
async function loadTodayOnlyTbm() {
await window.TbmAPI.loadTodayOnlyTbm();
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 관리 탭 ====================
// 레거시 호환 → api.js의 window alias 사용
// ==================== 날짜별 그룹 TBM 로드 (새 기능) ====================
function isAdminUser() { return window.TbmState.isAdminUser(); }
/**
* 최근 TBM을 날짜별로 그룹화하여 로드 → TbmAPI 위임
*/
async function loadRecentTbmGroupedByDate() {
await window.TbmAPI.loadRecentTbmGroupedByDate();
// TbmState에 dateGroupedSessions, allLoadedSessions가 설정됨
displayTbmGroupedByDate();
updateViewModeIndicator();
}
window.loadRecentTbmGroupedByDate = loadRecentTbmGroupedByDate;
/**
* 뷰 모드 표시 업데이트
*/
function updateViewModeIndicator() {
const indicator = document.getElementById('viewModeIndicator');
const text = document.getElementById('viewModeText');
if (indicator && text) {
if (isAdminUser()) {
indicator.style.display = 'none'; // Admin은 표시 안 함 (전체가 기본)
} else {
indicator.style.display = 'inline-flex';
text.textContent = '내 TBM';
}
}
}
/**
* 날짜별 그룹으로 TBM 표시
*/
function displayTbmGroupedByDate() {
const container = document.getElementById('tbmDateGroupsContainer');
const emptyState = document.getElementById('emptyState');
const totalSessionsEl = document.getElementById('totalSessions');
const completedSessionsEl = document.getElementById('completedSessions');
if (!container) return;
// 날짜별로 정렬 (최신순)
const sortedDates = Object.keys(dateGroupedSessions).sort((a, b) => new Date(b) - new Date(a));
if (sortedDates.length === 0 || allLoadedSessions.length === 0) {
container.innerHTML = '';
if (emptyState) emptyState.style.display = 'flex';
if (totalSessionsEl) totalSessionsEl.textContent = '0';
if (completedSessionsEl) completedSessionsEl.textContent = '0';
return;
}
if (emptyState) emptyState.style.display = 'none';
// 통계 업데이트
const completedCount = allLoadedSessions.filter(s => s.status === 'completed').length;
if (totalSessionsEl) totalSessionsEl.textContent = allLoadedSessions.length;
if (completedSessionsEl) completedSessionsEl.textContent = completedCount;
// 날짜별 그룹 HTML 생성
const today = getTodayKST();
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
container.innerHTML = sortedDates.map(date => {
const sessions = dateGroupedSessions[date];
const dateObj = new Date(date + 'T00:00:00');
const dayName = dayNames[dateObj.getDay()];
const isToday = date === today;
// 날짜 포맷팅 (YYYY-MM-DD → MM월 DD일)
const [year, month, day] = date.split('-');
const displayDate = `${parseInt(month)}${parseInt(day)}`;
return `
<div class="tbm-date-group" data-date="${date}">
<div class="tbm-date-header ${isToday ? 'today' : ''}" onclick="toggleDateGroup('${date}')">
<span class="tbm-date-toggle">&#9660;</span>
<span class="tbm-date-title">${displayDate}</span>
<span class="tbm-date-day">${dayName}요일</span>
${isToday ? '<span class="tbm-today-badge">오늘</span>' : ''}
<span class="tbm-date-count">${sessions.length}건</span>
</div>
<div class="tbm-date-content">
<div class="tbm-date-grid">
${sessions.map(session => createSessionCard(session)).join('')}
</div>
</div>
</div>
`;
}).join('');
}
// 날짜 그룹 토글
function toggleDateGroup(date) {
const group = document.querySelector(`.tbm-date-group[data-date="${date}"]`);
if (group) {
group.classList.toggle('collapsed');
}
}
window.toggleDateGroup = toggleDateGroup;
/**
* 더 많은 날짜 로드
*/
// loadMoreTbmDays → api.js의 window alias 사용
// 특정 날짜의 TBM 세션 목록 로드 → TbmAPI 위임
async function loadTbmSessionsByDate(date) {
await window.TbmAPI.loadTbmSessionsByDate(date);
displayTbmSessions();
}
// TBM 세션 목록 표시 (관리 탭용) - 레거시 호환 (날짜별 그룹 뷰 사용)
function displayTbmSessions() {
// 새 날짜별 그룹 뷰로 리다이렉트
if (allSessions.length > 0) {
// allSessions를 날짜별로 그룹화
dateGroupedSessions = {};
allSessions.forEach(session => {
const date = formatDate(session.session_date);
if (!dateGroupedSessions[date]) {
dateGroupedSessions[date] = [];
}
dateGroupedSessions[date].push(session);
});
allLoadedSessions = allSessions;
}
displayTbmGroupedByDate();
}
// TBM 세션 카드 생성 (공통)
function createSessionCard(session) {
const statusBadge = {
'draft': '<span class="tbm-card-status draft">진행중</span>',
'completed': '<span class="tbm-card-status completed">완료</span>',
'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
}[session.status] || '';
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="${onClickAction}">
<div class="tbm-card-header">
<div class="tbm-card-header-top">
<div>
<h3 class="tbm-card-leader">
${leaderName}
<span class="tbm-card-leader-role">${leaderRole}</span>
</h3>
</div>
${statusBadge}
</div>
<div class="tbm-card-date">
<span>&#128197;</span>
${escapeHtml(formatDate(session.session_date))} ${session.start_time ? '| ' + escapeHtml(session.start_time) : ''}
</div>
</div>
<div class="tbm-card-body">
<div class="tbm-card-info-grid">
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">프로젝트</span>
<span class="tbm-card-info-value">${escapeHtml(session.project_name || '-')}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">공정</span>
<span class="tbm-card-info-value">${escapeHtml(session.work_type_name || '-')}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">작업장</span>
<span class="tbm-card-info-value">${escapeHtml(session.work_location || '-')}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">팀원</span>
<span class="tbm-card-info-value">${escapeHtml(session.team_member_names || '')}${session.team_member_names ? '' : '없음'}</span>
</div>
</div>
</div>
${session.status === 'draft' ? `
<div class="tbm-card-footer">
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
&#128101; 세부 편집
</button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
&#10003; 안전 체크
</button>
<button class="tbm-btn tbm-btn-danger tbm-btn-sm" onclick="event.stopPropagation(); confirmDeleteTbm(${safeSessionId})">
&#128465; 삭제
</button>
</div>
` : ''}
</div>
`;
}
// 새 TBM 모달 열기 (간소화: 프로젝트+공정+작업자만)
function openNewTbmModal() {
if (window.innerWidth <= 768) {
window.location.href = '/pages/work/tbm-create.html';
return;
}
currentSessionId = null;
workerTaskList = [];
selectedWorkersForNewTbm = new Set();
todayAssignmentsMap = null; // 배정 현황 캐시 초기화
document.getElementById('modalTitle').innerHTML = '<span>&#128221;</span> 새 TBM 시작';
document.getElementById('sessionId').value = '';
document.getElementById('tbmForm').reset();
const today = getTodayKST();
document.getElementById('sessionDate').value = today;
// 날짜 표시 업데이트
const [year, month, day] = today.split('-');
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dateObj = new Date(today);
const dayName = dayNames[dateObj.getDay()];
const sessionDateDisplay = document.getElementById('sessionDateDisplay');
if (sessionDateDisplay) {
sessionDateDisplay.textContent = `${year}${parseInt(month)}${parseInt(day)}일 (${dayName})`;
}
// 입력자 자동 설정 (readonly)
if (currentUser && currentUser.user_id) {
const worker = allWorkers.find(w => w.user_id === currentUser.user_id);
if (worker) {
document.getElementById('leaderName').textContent = worker.worker_name;
document.getElementById('leaderId').value = worker.user_id;
}
} else if (currentUser && currentUser.name) {
document.getElementById('leaderName').textContent = currentUser.name;
document.getElementById('leaderId').value = '';
}
// 프로젝트 드롭다운 채우기
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 assignments = await window.TbmAPI.loadTodayAssignments(today);
todayAssignmentsMap = {};
assignments.forEach(a => {
if (a.sessions && a.sessions.length > 0) {
todayAssignmentsMap[a.user_id] = a;
}
});
} catch(e) {
console.error('배정 현황 로드 오류:', e);
todayAssignmentsMap = {};
}
}
grid.innerHTML = allWorkers.map(w => {
const checked = selectedWorkersForNewTbm.has(w.user_id) ? 'checked' : '';
const assignment = todayAssignmentsMap[w.user_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.user_id}" style="${disabledStyle}">
<input type="checkbox" class="new-tbm-worker-cb" data-user-id="${w.user_id}" ${checked} ${disabledAttr}
onchange="toggleNewTbmWorker(${w.user_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.user_id];
if (a && a.total_hours >= 8) return; // 종일 배정 제외
selectedWorkersForNewTbm.add(w.user_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');
if (!leaderSelect) return;
// 로그인한 사용자가 작업자와 연결되어 있는지 확인
if (currentUser && currentUser.user_id) {
// 작업자와 연결된 경우: 자동으로 선택하고 비활성화
const worker = allWorkers.find(w => w.user_id === currentUser.user_id);
if (worker) {
const jobTypeText = worker.job_type ? ` (${escapeHtml(worker.job_type)})` : '';
leaderSelect.innerHTML = `<option value="${escapeHtml(worker.user_id)}" selected>${escapeHtml(worker.worker_name)}${jobTypeText}</option>`;
leaderSelect.disabled = true;
console.log('✅ 입력자 자동 설정:', worker.worker_name);
} else {
// 작업자를 찾을 수 없는 경우
leaderSelect.innerHTML = '<option value="">입력자를 찾을 수 없습니다</option>';
leaderSelect.disabled = true;
}
} else {
// 관리자 계정 (user_id가 없음): 드롭다운으로 선택 가능
const leaders = allWorkers.filter(w =>
w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin'
);
leaderSelect.innerHTML = '<option value="">입력자 선택...</option>' +
leaders.map(w => {
const jobTypeText = w.job_type ? ` (${escapeHtml(w.job_type)})` : '';
return `<option value="${escapeHtml(w.user_id)}">${escapeHtml(w.worker_name)}${jobTypeText}</option>`;
}).join('');
leaderSelect.disabled = false;
console.log('✅ 관리자: 입력자 선택 가능');
}
}
// 프로젝트 선택 드롭다운 채우기
function populateProjectSelect() {
const projectSelect = document.getElementById('projectId');
if (!projectSelect) return;
projectSelect.innerHTML = '<option value="">프로젝트 선택...</option>' +
allProjects.map(p => `
<option value="${escapeHtml(p.project_id)}">${escapeHtml(p.project_name)} (${escapeHtml(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="${escapeHtml(wt.id)}">${escapeHtml(wt.name)}${wt.category ? ' (' + escapeHtml(wt.category) + ')' : ''}</option>
`).join('');
}
// 작업장 선택 드롭다운 채우기
function populateWorkplaceSelect() {
const workLocationSelect = document.getElementById('workLocation');
if (!workLocationSelect) return;
workLocationSelect.innerHTML = '<option value="">작업장 선택...</option>' +
allWorkplaces.map(wp => `
<option value="${escapeHtml(wp.workplace_name)}">${escapeHtml(wp.workplace_name)}${wp.location ? ' - ' + escapeHtml(wp.location) : ''}</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="${escapeHtml(String(task.task_id))}">${escapeHtml(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';
unlockBodyScroll();
}
window.closeTbmModal = closeTbmModal;
// TBM 세션 저장 (간소화: 프로젝트+공정+작업자, task/workplace=null)
async function saveTbmSession() {
console.log('💾 TBM 저장 시작...');
let leaderId = parseInt(document.getElementById('leaderId').value);
if (!leaderId || isNaN(leaderId)) {
if (!currentUser.user_id) {
console.log('📝 관리자 계정: leader_user_id를 NULL로 설정');
leaderId = null;
} else {
console.error('❌ 입력자 설정 오류');
showToast('입력자 정보가 올바르지 않습니다.', 'error');
return;
}
}
const sessionData = {
session_date: document.getElementById('sessionDate').value,
leader_user_id: leaderId
};
if (!sessionData.session_date) {
showToast('TBM 날짜를 확인해주세요.', 'error');
return;
}
const editingSessionId = document.getElementById('sessionId').value;
// 수정 모드일 때는 기존 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({
user_id: workerData.user_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
});
}
}
try {
await window.TbmAPI.clearTeamMembers(editingSessionId);
const teamResponse = await window.TbmAPI.addTeamMembers(editingSessionId, members);
if (teamResponse && teamResponse.success) {
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명)`, 'success');
closeTbmModal();
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadRecentTbmGroupedByDate();
}
} else {
throw new Error(teamResponse.message || '팀원 수정에 실패했습니다.');
}
} catch (error) {
console.error('❌ TBM 세션 수정 오류:', error);
showToast('TBM 세션 수정 중 오류가 발생했습니다.', 'error');
}
return;
}
// 생성 모드: 간소화된 새 TBM
const workTypeId = parseInt(document.getElementById('newTbmWorkTypeId')?.value);
const projectId = parseInt(document.getElementById('newTbmProjectId')?.value) || null;
if (!workTypeId) {
showToast('공정을 선택해주세요.', 'error');
return;
}
if (selectedWorkersForNewTbm.size === 0) {
showToast('최소 1명 이상의 작업자를 선택해주세요.', 'error');
return;
}
// 작업자별 members 생성 (task_id, workplace_id = null)
const members = [];
selectedWorkersForNewTbm.forEach(workerId => {
members.push({
user_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
});
});
try {
const response = await window.TbmAPI.createTbmSession(sessionData);
if (response && response.success) {
const createdSessionId = response.data.session_id;
console.log('✅ TBM 세션 생성 완료:', createdSessionId);
const teamResponse = await window.TbmAPI.addTeamMembers(createdSessionId, members);
if (teamResponse && teamResponse.success) {
showToast(`TBM이 생성되었습니다 (작업자 ${members.length}명)`, 'success');
closeTbmModal();
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadRecentTbmGroupedByDate();
}
} else {
throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.');
}
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('❌ TBM 세션 저장 오류:', error);
showToast('TBM 세션 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveTbmSession = saveTbmSession;
// ==================== 작업자 관리 ====================
// generateUUID → api-base.js 전역 사용
// 작업자 카드 리스트 렌더링
function renderWorkerTaskList() {
const listContainer = document.getElementById('workerTaskList');
const emptyState = document.getElementById('workerListEmpty');
if (workerTaskList.length === 0) {
if (emptyState) emptyState.style.display = 'flex';
listContainer.innerHTML = '';
return;
}
if (emptyState) emptyState.style.display = 'none';
listContainer.innerHTML = workerTaskList.map((workerData, workerIndex) => {
return `
<div style="border: 2px solid #e5e7eb; border-radius: 0.5rem; padding: 1rem; background: white;">
<!-- 작업자 헤더 -->
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid #e5e7eb;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-weight: 600; font-size: 1.1rem; color: #1f2937;">👤 ${escapeHtml(workerData.worker_name)}</span>
<span style="padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem;">
${escapeHtml(workerData.job_type || '작업자')}
</span>
</div>
<button type="button" onclick="removeWorkerFromList(${parseInt(workerIndex) || 0})" class="btn btn-sm btn-danger">
<span style="font-size: 1rem;">✕ 작업자 제거</span>
</button>
</div>
<!-- 작업 라인들 -->
${workerData.tasks.map((taskLine, taskIndex) => renderTaskLine(workerData, workerIndex, taskLine, taskIndex)).join('')}
<!-- 작업 추가 버튼 -->
<div style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed #d1d5db;">
<button type="button" onclick="addTaskLineToWorker(${parseInt(workerIndex) || 0})" class="btn btn-sm btn-secondary" style="width: 100%;">
<span style="font-size: 1.2rem; margin-right: 0.25rem;">+</span>
이 작업자의 추가 작업 등록
</button>
</div>
</div>
`;
}).join('');
}
// 작업 라인 렌더링
function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
const project = allProjects.find(p => p.project_id === taskLine.project_id);
const workType = allWorkTypes.find(wt => wt.id === taskLine.work_type_id);
const task = allTasks.find(t => t.task_id === taskLine.task_id);
const safeWorkerIndex = parseInt(workerIndex) || 0;
const safeTaskIndex = parseInt(taskIndex) || 0;
const projectText = escapeHtml(project ? project.project_name : '프로젝트 선택');
const workTypeText = escapeHtml(workType ? workType.name : '공정 선택 *');
const taskText = escapeHtml(task ? task.task_name : '작업 선택 *');
const workplaceText = taskLine.workplace_name
? escapeHtml(`${taskLine.workplace_category_name || ''}${taskLine.workplace_name}`)
: '작업장 선택 *';
return `
<div style="padding: 0.75rem; margin-bottom: 0.5rem; background: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<div class="tbm-task-grid" style="margin-bottom: 0.5rem;">
<!-- 프로젝트 선택 -->
<button type="button"
onclick="openItemSelect('project', ${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm ${project ? 'btn-primary' : 'btn-secondary'}"
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;">
📁 ${projectText}
</button>
<!-- 작업장 선택 -->
<button type="button"
onclick="openWorkplaceSelect(${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm ${taskLine.workplace_id ? 'btn-primary' : 'btn-secondary'}"
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;">
📍 ${workplaceText}
</button>
<!-- 공정 선택 -->
<button type="button"
onclick="openItemSelect('workType', ${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm ${workType ? 'btn-primary' : 'btn-secondary'}"
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;">
⚙️ ${workTypeText}
</button>
<!-- 작업 선택 -->
<button type="button"
onclick="openItemSelect('task', ${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm ${task ? 'btn-primary' : 'btn-secondary'}"
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;"
${!taskLine.work_type_id ? 'disabled' : ''}>
🔧 ${taskText}
</button>
</div>
<!-- 작업 라인 제거 버튼 -->
${workerData.tasks.length > 1 ? `
<button type="button" onclick="removeTaskLine(${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm btn-danger" style="width: 100%; font-size: 0.8rem;">
<span style="margin-right: 0.25rem;"></span> 이 작업 라인 제거
</button>
` : ''}
</div>
`;
}
window.renderWorkerTaskList = renderWorkerTaskList;
// 작업자 선택 모달 열기
function openWorkerSelectionModal() {
selectedWorkersInModal.clear();
const workerCardGrid = document.getElementById('workerCardGrid');
if (!workerCardGrid) return;
// 이미 추가된 작업자 ID 세트
const addedWorkerIds = new Set(workerTaskList.map(w => w.user_id));
workerCardGrid.innerHTML = allWorkers.map(worker => {
const isAdded = addedWorkerIds.has(worker.user_id);
const safeWorkerId = parseInt(worker.user_id) || 0;
return `
<div id="worker-card-${safeWorkerId}"
onclick="toggleWorkerSelection(${safeWorkerId})"
style="padding: 1rem; border: 2px solid ${isAdded ? '#d1d5db' : '#e5e7eb'}; border-radius: 0.5rem; cursor: ${isAdded ? 'not-allowed' : 'pointer'}; background: ${isAdded ? '#f3f4f6' : 'white'}; opacity: ${isAdded ? '0.5' : '1'}; transition: all 0.2s;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
${isAdded ? '✓' : '☐'}
<span style="font-weight: 600; font-size: 0.95rem;">${escapeHtml(worker.worker_name)}</span>
</div>
<div style="font-size: 0.8rem; color: #6b7280;">
${escapeHtml(worker.job_type || '작업자')}${worker.department ? ' · ' + escapeHtml(worker.department) : ''}
</div>
${isAdded ? '<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">이미 추가됨</div>' : ''}
</div>
`;
}).join('');
document.getElementById('workerSelectionModal').style.display = 'flex';
lockBodyScroll();
}
window.openWorkerSelectionModal = openWorkerSelectionModal;
// 작업자 선택 토글
function toggleWorkerSelection(workerId) {
// 이미 추가된 작업자는 선택 불가
const alreadyAdded = workerTaskList.some(w => w.user_id === workerId);
if (alreadyAdded) return;
const card = document.getElementById(`worker-card-${workerId}`);
if (!card) return;
if (selectedWorkersInModal.has(workerId)) {
selectedWorkersInModal.delete(workerId);
card.style.borderColor = '#e5e7eb';
card.style.background = 'white';
card.innerHTML = card.innerHTML.replace('☑', '☐');
} else {
selectedWorkersInModal.add(workerId);
card.style.borderColor = '#3b82f6';
card.style.background = '#eff6ff';
card.innerHTML = card.innerHTML.replace('☐', '☑');
}
}
window.toggleWorkerSelection = toggleWorkerSelection;
// 전체 선택
function selectAllWorkersInModal() {
const addedWorkerIds = new Set(workerTaskList.map(w => w.user_id));
allWorkers.forEach(worker => {
if (!addedWorkerIds.has(worker.user_id)) {
selectedWorkersInModal.add(worker.user_id);
const card = document.getElementById(`worker-card-${worker.user_id}`);
if (card) {
card.style.borderColor = '#3b82f6';
card.style.background = '#eff6ff';
card.innerHTML = card.innerHTML.replace('☐', '☑');
}
}
});
}
window.selectAllWorkersInModal = selectAllWorkersInModal;
// 전체 해제
function deselectAllWorkersInModal() {
selectedWorkersInModal.forEach(workerId => {
const card = document.getElementById(`worker-card-${workerId}`);
if (card) {
card.style.borderColor = '#e5e7eb';
card.style.background = 'white';
card.innerHTML = card.innerHTML.replace('☑', '☐');
}
});
selectedWorkersInModal.clear();
}
window.deselectAllWorkersInModal = deselectAllWorkersInModal;
// 작업자 선택 확정
function confirmWorkerSelection() {
if (selectedWorkersInModal.size === 0) {
showToast('작업자를 선택해주세요.', 'error');
return;
}
selectedWorkersInModal.forEach(workerId => {
const worker = allWorkers.find(w => w.user_id === workerId);
if (worker) {
workerTaskList.push({
user_id: worker.user_id,
worker_name: worker.worker_name,
job_type: worker.job_type,
tasks: [
{
task_line_id: generateUUID(),
project_id: null,
work_type_id: null,
task_id: null,
workplace_category_id: null,
workplace_id: null,
workplace_category_name: '',
workplace_name: '',
work_detail: null,
is_present: true
}
]
});
}
});
renderWorkerTaskList();
closeWorkerSelectionModal();
showToast(`${selectedWorkersInModal.size}명의 작업자가 추가되었습니다.`, 'success');
}
window.confirmWorkerSelection = confirmWorkerSelection;
// 작업자 선택 모달 닫기
function closeWorkerSelectionModal() {
document.getElementById('workerSelectionModal').style.display = 'none';
unlockBodyScroll();
selectedWorkersInModal.clear();
}
window.closeWorkerSelectionModal = closeWorkerSelectionModal;
// 작업자에 작업 라인 추가
function addTaskLineToWorker(workerIndex) {
workerTaskList[workerIndex].tasks.push({
task_line_id: generateUUID(),
project_id: null,
work_type_id: null,
task_id: null,
workplace_category_id: null,
workplace_id: null,
workplace_category_name: '',
workplace_name: '',
work_detail: null,
is_present: true
});
renderWorkerTaskList();
showToast('작업 라인이 추가되었습니다.', 'success');
}
window.addTaskLineToWorker = addTaskLineToWorker;
// 작업 라인 제거
function removeTaskLine(workerIndex, taskIndex) {
workerTaskList[workerIndex].tasks.splice(taskIndex, 1);
renderWorkerTaskList();
showToast('작업 라인이 제거되었습니다.', 'info');
}
window.removeTaskLine = removeTaskLine;
// 작업자 제거
function removeWorkerFromList(workerIndex) {
const workerName = workerTaskList[workerIndex].worker_name;
workerTaskList.splice(workerIndex, 1);
renderWorkerTaskList();
showToast(`${workerName}이(가) 제거되었습니다.`, 'info');
}
window.removeWorkerFromList = removeWorkerFromList;
// ==================== 일괄 설정 ====================
// 일괄 설정 모달 열기
function openBulkSettingModal() {
if (workerTaskList.length === 0) {
showToast('먼저 작업자를 선택해주세요.', 'error');
return;
}
// 작업자 선택 영역 초기화
bulkSelectedWorkers.clear();
renderBulkWorkerSelection();
// 작업 정보 초기화
document.getElementById('bulkProjectId').value = '';
document.getElementById('bulkWorkTypeId').value = '';
document.getElementById('bulkTaskId').value = '';
document.getElementById('bulkWorkplaceCategoryId').value = '';
document.getElementById('bulkWorkplaceId').value = '';
document.getElementById('bulkProjectBtn').textContent = '📁 프로젝트 선택';
document.getElementById('bulkProjectBtn').classList.remove('btn-primary');
document.getElementById('bulkProjectBtn').classList.add('btn-secondary');
document.getElementById('bulkWorkTypeBtn').textContent = '⚙️ 공정 선택';
document.getElementById('bulkWorkTypeBtn').classList.remove('btn-primary');
document.getElementById('bulkWorkTypeBtn').classList.add('btn-secondary');
document.getElementById('bulkTaskBtn').textContent = '🔧 작업 선택';
document.getElementById('bulkTaskBtn').classList.remove('btn-primary');
document.getElementById('bulkTaskBtn').classList.add('btn-secondary');
document.getElementById('bulkTaskBtn').disabled = true;
document.getElementById('bulkWorkplaceBtn').textContent = '📍 작업장 선택';
document.getElementById('bulkWorkplaceBtn').classList.remove('btn-primary');
document.getElementById('bulkWorkplaceBtn').classList.add('btn-secondary');
document.getElementById('bulkSettingModal').style.display = 'flex';
lockBodyScroll();
}
window.openBulkSettingModal = openBulkSettingModal;
// 일괄 설정용 작업자 선택 영역 렌더링
function renderBulkWorkerSelection() {
const container = document.getElementById('bulkWorkerSelection');
if (!container) return;
container.innerHTML = workerTaskList.map((workerData, index) => {
const isSelected = bulkSelectedWorkers.has(index);
return `
<div onclick="toggleBulkWorkerSelection(${index})"
style="padding: 0.5rem; border: 2px solid ${isSelected ? '#3b82f6' : '#e5e7eb'}; border-radius: 0.5rem; cursor: pointer; background: ${isSelected ? '#eff6ff' : 'white'}; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" ${isSelected ? 'checked' : ''} style="pointer-events: none;">
<span style="font-size: 0.875rem; font-weight: ${isSelected ? '600' : '400'}; color: ${isSelected ? '#1e40af' : '#374151'};">
${workerData.worker_name}
</span>
</div>
`;
}).join('');
}
window.renderBulkWorkerSelection = renderBulkWorkerSelection;
// 일괄 설정용 작업자 선택 토글
function toggleBulkWorkerSelection(workerIndex) {
if (bulkSelectedWorkers.has(workerIndex)) {
bulkSelectedWorkers.delete(workerIndex);
} else {
bulkSelectedWorkers.add(workerIndex);
}
renderBulkWorkerSelection();
}
window.toggleBulkWorkerSelection = toggleBulkWorkerSelection;
// 일괄 설정용 전체 선택
function selectAllForBulk() {
workerTaskList.forEach((_, index) => {
bulkSelectedWorkers.add(index);
});
renderBulkWorkerSelection();
}
window.selectAllForBulk = selectAllForBulk;
// 일괄 설정용 전체 해제
function deselectAllForBulk() {
bulkSelectedWorkers.clear();
renderBulkWorkerSelection();
}
window.deselectAllForBulk = deselectAllForBulk;
// 일괄 설정 모달 닫기
function closeBulkSettingModal() {
document.getElementById('bulkSettingModal').style.display = 'none';
unlockBodyScroll();
isBulkMode = false;
}
window.closeBulkSettingModal = closeBulkSettingModal;
// 일괄 설정용 항목 선택
function openBulkItemSelect(type) {
isBulkMode = true;
const modal = document.getElementById('itemSelectModal');
const titleEl = document.getElementById('itemSelectModalTitle');
const listEl = document.getElementById('itemSelectList');
let title = '';
let items = [];
if (type === 'project') {
title = '프로젝트 선택';
// 활성 프로젝트만 표시
const activeProjects = allProjects.filter(p =>
p.is_active === 1 || p.is_active === true || p.is_active === '1'
);
items = activeProjects.map(p => ({
id: p.project_id,
name: p.project_name,
icon: '📁'
}));
} else if (type === 'workType') {
title = '공정 선택';
items = allWorkTypes.map(wt => ({
id: wt.id,
name: wt.name,
icon: '⚙️'
}));
} else if (type === 'task') {
title = '작업 선택';
const currentWorkTypeId = parseInt(document.getElementById('bulkWorkTypeId').value);
if (!currentWorkTypeId) {
showToast('공정을 먼저 선택해주세요.', 'error');
return;
}
const filteredTasks = allTasks.filter(t => t.work_type_id === currentWorkTypeId);
items = filteredTasks.map(t => ({
id: t.task_id,
name: t.task_name,
icon: '🔧'
}));
}
titleEl.textContent = title;
listEl.innerHTML = items.length > 0 ? items.map(item => `
<button type="button"
onclick="selectBulkItem('${type}', ${item.id}, '${item.name.replace(/'/g, "\\'")}')"
class="btn btn-secondary"
style="text-align: left; justify-content: flex-start; padding: 0.75rem;">
${item.icon} ${item.name}
</button>
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
modal.style.display = 'flex';
lockBodyScroll();
}
window.openBulkItemSelect = openBulkItemSelect;
// 일괄 설정용 항목 선택 처리
function selectBulkItem(type, itemId, itemName) {
if (type === 'project') {
document.getElementById('bulkProjectId').value = itemId;
document.getElementById('bulkProjectBtn').textContent = `📁 ${itemName}`;
document.getElementById('bulkProjectBtn').classList.remove('btn-secondary');
document.getElementById('bulkProjectBtn').classList.add('btn-primary');
} else if (type === 'workType') {
document.getElementById('bulkWorkTypeId').value = itemId;
document.getElementById('bulkWorkTypeBtn').textContent = `⚙️ ${itemName}`;
document.getElementById('bulkWorkTypeBtn').classList.remove('btn-secondary');
document.getElementById('bulkWorkTypeBtn').classList.add('btn-primary');
// 공정 변경 시 작업 초기화
document.getElementById('bulkTaskId').value = '';
document.getElementById('bulkTaskBtn').textContent = '🔧 작업 선택';
document.getElementById('bulkTaskBtn').classList.remove('btn-primary');
document.getElementById('bulkTaskBtn').classList.add('btn-secondary');
document.getElementById('bulkTaskBtn').disabled = false;
} else if (type === 'task') {
document.getElementById('bulkTaskId').value = itemId;
document.getElementById('bulkTaskBtn').textContent = `🔧 ${itemName}`;
document.getElementById('bulkTaskBtn').classList.remove('btn-secondary');
document.getElementById('bulkTaskBtn').classList.add('btn-primary');
}
closeItemSelectModal();
isBulkMode = false;
}
window.selectBulkItem = selectBulkItem;
// 일괄 설정용 작업장 선택
function openBulkWorkplaceSelect() {
isBulkMode = true;
loadWorkplaceCategories();
document.getElementById('workplaceSelectModal').style.display = 'flex';
lockBodyScroll();
}
window.openBulkWorkplaceSelect = openBulkWorkplaceSelect;
// 일괄 설정 적용
function applyBulkSettings() {
if (bulkSelectedWorkers.size === 0) {
showToast('작업자를 선택해주세요.', 'error');
return;
}
const projectId = document.getElementById('bulkProjectId').value;
const workTypeId = document.getElementById('bulkWorkTypeId').value;
const taskId = document.getElementById('bulkTaskId').value;
const workplaceCategoryId = document.getElementById('bulkWorkplaceCategoryId').value;
const workplaceId = document.getElementById('bulkWorkplaceId').value;
if (!workTypeId || !taskId || !workplaceId) {
showToast('공정, 작업, 작업장은 필수 항목입니다.', 'error');
return;
}
// 선택된 작업자들의 첫 번째 작업 라인에 적용
let appliedCount = 0;
bulkSelectedWorkers.forEach(workerIndex => {
const workerData = workerTaskList[workerIndex];
if (workerData && workerData.tasks.length > 0) {
workerData.tasks[0].project_id = projectId ? parseInt(projectId) : null;
workerData.tasks[0].work_type_id = parseInt(workTypeId);
workerData.tasks[0].task_id = parseInt(taskId);
workerData.tasks[0].workplace_category_id = workplaceCategoryId ? parseInt(workplaceCategoryId) : null;
workerData.tasks[0].workplace_id = parseInt(workplaceId);
workerData.tasks[0].workplace_category_name = selectedCategoryName;
workerData.tasks[0].workplace_name = selectedWorkplaceName;
appliedCount++;
}
});
renderWorkerTaskList();
closeBulkSettingModal();
showToast(`${appliedCount}명의 작업자에게 일괄 설정이 적용되었습니다.`, 'success');
}
window.applyBulkSettings = applyBulkSettings;
// ==================== 항목 선택 (프로젝트/공정/작업) ====================
// 항목 선택 모달 열기
function openItemSelect(type, workerIndex, taskIndex) {
currentEditingTaskLine = { workerIndex, taskIndex };
const modal = document.getElementById('itemSelectModal');
const titleEl = document.getElementById('itemSelectModalTitle');
const listEl = document.getElementById('itemSelectList');
let title = '';
let items = [];
if (type === 'project') {
title = '프로젝트 선택';
// 활성 프로젝트만 표시
const activeProjects = allProjects.filter(p =>
p.is_active === 1 || p.is_active === true || p.is_active === '1'
);
items = activeProjects.map(p => ({
id: p.project_id,
name: p.project_name,
icon: '📁'
}));
} else if (type === 'workType') {
title = '공정 선택';
items = allWorkTypes.map(wt => ({
id: wt.id,
name: wt.name,
icon: '⚙️'
}));
} else if (type === 'task') {
title = '작업 선택';
const currentWorkTypeId = workerTaskList[workerIndex].tasks[taskIndex].work_type_id;
if (!currentWorkTypeId) {
showToast('공정을 먼저 선택해주세요.', 'error');
return;
}
const filteredTasks = allTasks.filter(t => t.work_type_id === currentWorkTypeId);
items = filteredTasks.map(t => ({
id: t.task_id,
name: t.task_name,
icon: '🔧'
}));
}
titleEl.textContent = title;
listEl.innerHTML = items.length > 0 ? items.map(item => `
<button type="button"
onclick="selectItem('${type}', ${item.id})"
class="btn btn-secondary"
style="text-align: left; justify-content: flex-start; padding: 0.75rem;">
${item.icon} ${item.name}
</button>
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
modal.style.display = 'flex';
lockBodyScroll();
}
window.openItemSelect = openItemSelect;
// 항목 선택
function selectItem(type, itemId) {
// 일괄 모드면 여기서 처리하지 않음
if (isBulkMode) return;
if (!currentEditingTaskLine) return;
const { workerIndex, taskIndex } = currentEditingTaskLine;
const taskLine = workerTaskList[workerIndex].tasks[taskIndex];
if (type === 'project') {
taskLine.project_id = itemId;
} else if (type === 'workType') {
taskLine.work_type_id = itemId;
// 공정 변경 시 작업 초기화
taskLine.task_id = null;
} else if (type === 'task') {
taskLine.task_id = itemId;
}
renderWorkerTaskList();
closeItemSelectModal();
}
window.selectItem = selectItem;
// 항목 선택 모달 닫기
function closeItemSelectModal() {
document.getElementById('itemSelectModal').style.display = 'none';
unlockBodyScroll();
currentEditingTaskLine = null;
}
window.closeItemSelectModal = closeItemSelectModal;
// ==================== 작업장 2단계 선택 ====================
// 작업장 선택 모달 열기 (작업 라인용)
async function openWorkplaceSelect(workerIndex, taskIndex) {
currentEditingTaskLine = { workerIndex, taskIndex };
await loadWorkplaceCategories();
document.getElementById('workplaceSelectModal').style.display = 'flex';
lockBodyScroll();
}
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';
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceList').style.display = 'none';
currentEditingTaskLine = null;
selectedCategory = null;
selectedWorkplace = null;
selectedCategoryName = '';
selectedWorkplaceName = '';
mapCanvas = null;
mapCtx = null;
mapImage = null;
mapRegions = [];
}
window.closeWorkplaceSelectModal = closeWorkplaceSelectModal;
// 공장 카테고리 로드
async function loadWorkplaceCategories() {
const categoryList = document.getElementById('categoryList');
if (!categoryList) return;
if (allWorkplaceCategories.length === 0) {
categoryList.innerHTML = '<div style="color: #9ca3af; text-align: center; padding: 2rem;">등록된 공장이 없습니다</div>';
return;
}
categoryList.innerHTML = allWorkplaceCategories.map(category => `
<button type="button"
onclick="selectCategory(${category.category_id}, '${category.category_name}')"
class="btn btn-secondary"
id="category-${category.category_id}"
style="text-align: left; justify-content: flex-start;">
🏭 ${category.category_name}
</button>
`).join('');
}
window.loadWorkplaceCategories = loadWorkplaceCategories;
// 공장 카테고리 선택
async function selectCategory(categoryId, categoryName) {
selectedCategory = categoryId;
selectedCategoryName = categoryName;
selectedWorkplace = null;
selectedWorkplaceName = '';
// 카테고리 버튼 스타일 업데이트
document.querySelectorAll('[id^="category-"]').forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
});
const selectedBtn = document.getElementById(`category-${categoryId}`);
if (selectedBtn) {
selectedBtn.classList.remove('btn-secondary');
selectedBtn.classList.add('btn-primary');
}
// 작업장 선택 영역 표시
document.getElementById('workplaceSelectionArea').style.display = 'block';
// 해당 카테고리 정보 가져오기
const category = allWorkplaceCategories.find(c => c.category_id === categoryId);
const isMobile = window.innerWidth <= 768;
// 지도 또는 리스트 로드
if (category && category.layout_image) {
// 지도가 있는 경우 - 지도를 기본 표시
await loadWorkplaceMap(categoryId, category.layout_image);
document.getElementById('layoutMapArea').style.display = 'block';
if (isMobile) {
// 모바일: 리스트 숨기고 "리스트로 선택" 토글 표시
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';
document.getElementById('workplaceListSection').style.display = 'block';
document.getElementById('toggleListBtn').style.display = 'none';
}
} else {
// 지도가 없는 경우 - 리스트만 표시
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('toggleListBtn').style.display = 'none';
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('workplaceListSection').style.display = 'block';
}
// 해당 카테고리의 작업장 리스트 로드
await loadWorkplacesByCategory(categoryId);
// 선택 완료 버튼 비활성화 (작업장 선택 필요)
document.getElementById('confirmWorkplaceBtn').disabled = true;
}
window.selectCategory = selectCategory;
// 카테고리별 작업장 로드
async function loadWorkplacesByCategory(categoryId) {
const workplaceList = document.getElementById('workplaceList');
if (!workplaceList) return;
try {
const workplaces = await window.TbmAPI.loadWorkplacesByCategory(categoryId);
if (!workplaces || workplaces.length === 0) {
workplaceList.innerHTML = '<div style="color: #9ca3af; text-align: center; padding: 2rem;">등록된 작업장이 없습니다</div>';
return;
}
workplaceList.innerHTML = workplaces.map(workplace => `
<button type="button"
onclick="selectWorkplace(${workplace.workplace_id}, '${workplace.workplace_name}')"
class="btn btn-secondary"
id="workplace-${workplace.workplace_id}"
style="text-align: left; justify-content: flex-start;">
📍 ${workplace.workplace_name}
</button>
`).join('');
} catch (error) {
console.error('❌ 작업장 로드 오류:', error);
workplaceList.innerHTML = '<div style="color: #ef4444; text-align: center; padding: 2rem;">작업장을 불러오는 중 오류가 발생했습니다</div>';
}
}
window.loadWorkplacesByCategory = loadWorkplacesByCategory;
// 작업장 선택
function selectWorkplace(workplaceId, workplaceName) {
selectedWorkplace = workplaceId;
selectedWorkplaceName = workplaceName;
// 작업장 버튼 스타일 업데이트 (리스트)
document.querySelectorAll('[id^="workplace-"]').forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
});
const selectedBtn = document.getElementById(`workplace-${workplaceId}`);
if (selectedBtn) {
selectedBtn.classList.remove('btn-secondary');
selectedBtn.classList.add('btn-primary');
}
// 지도 업데이트 (지도가 로드되어 있는 경우)
if (mapCanvas && mapCtx && mapImage) {
drawWorkplaceMap();
}
// 선택 완료 버튼 활성화
document.getElementById('confirmWorkplaceBtn').disabled = false;
}
window.selectWorkplace = selectWorkplace;
// 작업장 선택 확정
function confirmWorkplaceSelection() {
if (!selectedCategory || !selectedWorkplace) {
showToast('공장과 작업장을 모두 선택해주세요.', 'error');
return;
}
// 일괄 모드인 경우
if (isBulkMode) {
document.getElementById('bulkWorkplaceCategoryId').value = selectedCategory;
document.getElementById('bulkWorkplaceId').value = selectedWorkplace;
document.getElementById('bulkWorkplaceBtn').textContent = `📍 ${selectedCategoryName}${selectedWorkplaceName}`;
document.getElementById('bulkWorkplaceBtn').classList.remove('btn-secondary');
document.getElementById('bulkWorkplaceBtn').classList.add('btn-primary');
closeWorkplaceSelectModal();
isBulkMode = false;
showToast('작업장이 선택되었습니다.', 'success');
return;
}
// 현재 편집 중인 작업 라인에 저장
if (currentEditingTaskLine) {
const { workerIndex, taskIndex } = currentEditingTaskLine;
const taskLine = workerTaskList[workerIndex].tasks[taskIndex];
taskLine.workplace_category_id = selectedCategory;
taskLine.workplace_id = selectedWorkplace;
taskLine.workplace_category_name = selectedCategoryName;
taskLine.workplace_name = selectedWorkplaceName;
renderWorkerTaskList();
}
closeWorkplaceSelectModal();
showToast('작업장이 선택되었습니다.', 'success');
}
window.confirmWorkplaceSelection = confirmWorkplaceSelection;
// 리스트 토글 함수 (레거시 호환)
// 리스트 토글 함수
function toggleWorkplaceList() {
const listSection = document.getElementById('workplaceListSection');
const btn = document.getElementById('toggleListBtn');
if (listSection.style.display === 'none') {
listSection.style.display = 'block';
document.getElementById('workplaceList').style.display = 'flex';
btn.textContent = '리스트 숨기기';
} else {
listSection.style.display = 'none';
btn.textContent = '리스트로 선택';
}
}
window.toggleWorkplaceList = toggleWorkplaceList;
// 작업장 지도 로드 및 렌더링
// mapRegions, mapCanvas, mapCtx, mapImage → TbmState 프록시 사용 (state.js)
async function loadWorkplaceMap(categoryId, layoutImagePath) {
try {
mapCanvas = document.getElementById('workplaceMapCanvas');
if (!mapCanvas) return;
mapCtx = mapCanvas.getContext('2d');
// 이미지 URL 생성
const baseUrl = window.API_BASE_URL || 'http://localhost:30005';
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
const fullImageUrl = layoutImagePath.startsWith('http')
? layoutImagePath
: `${apiBaseUrl}${layoutImagePath}`;
console.log('🖼️ 이미지 로드 시도:', fullImageUrl);
// 지도 영역 데이터 로드
mapRegions = await window.TbmAPI.loadMapRegions(categoryId);
// 이미지 로드
mapImage = new Image();
mapImage.crossOrigin = 'anonymous';
mapImage.onload = function() {
// 캔버스 크기 설정 (모바일 대응)
const maxWidth = window.innerWidth <= 768
? Math.min(window.innerWidth - 32, 600)
: 800;
const scale = mapImage.width > maxWidth ? maxWidth / mapImage.width : 1;
mapCanvas.width = mapImage.width * scale;
mapCanvas.height = mapImage.height * scale;
// 이미지와 영역 그리기
drawWorkplaceMap();
// 클릭 이벤트 리스너 추가
mapCanvas.onclick = handleMapClick;
console.log(`✅ 작업장 지도 로드 완료: ${mapRegions.length}개 영역`);
};
mapImage.onerror = function() {
console.error('❌ 지도 이미지 로드 실패');
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceListSection').style.display = 'block';
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('toggleListBtn').style.display = 'none';
showToast('지도를 불러올 수 없어 리스트로 표시합니다.', 'warning');
};
mapImage.src = fullImageUrl;
} catch (error) {
console.error('❌ 작업장 지도 로드 오류:', error);
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceList').style.display = 'flex';
}
}
window.loadWorkplaceMap = loadWorkplaceMap;
// 지도 그리기 (이미지 + 영역 + 라벨)
function drawWorkplaceMap() {
if (!mapCanvas || !mapCtx || !mapImage) return;
// 이미지 그리기
mapCtx.drawImage(mapImage, 0, 0, mapCanvas.width, mapCanvas.height);
// 각 영역 그리기
mapRegions.forEach((region, index) => {
// 퍼센트를 픽셀로 변환
const x1 = (region.x_start / 100) * mapCanvas.width;
const y1 = (region.y_start / 100) * mapCanvas.height;
const x2 = (region.x_end / 100) * mapCanvas.width;
const y2 = (region.y_end / 100) * mapCanvas.height;
const width = x2 - x1;
const height = y2 - y1;
// 선택된 영역인지 확인
const isSelected = region.workplace_id === selectedWorkplace;
// 영역 테두리
mapCtx.strokeStyle = isSelected ? '#3b82f6' : '#10b981';
mapCtx.lineWidth = isSelected ? 4 : 2;
mapCtx.strokeRect(x1, y1, width, height);
// 영역 배경 (반투명)
mapCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.25)' : 'rgba(16, 185, 129, 0.15)';
mapCtx.fillRect(x1, y1, width, height);
// 작업장 이름 표시
if (region.workplace_name) {
mapCtx.font = 'bold 14px sans-serif';
// 텍스트 배경
const textMetrics = mapCtx.measureText(region.workplace_name);
const textPadding = 6;
mapCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.95)' : 'rgba(16, 185, 129, 0.95)';
mapCtx.fillRect(x1 + 5, y1 + 5, textMetrics.width + textPadding * 2, 24);
// 텍스트
mapCtx.fillStyle = '#ffffff';
mapCtx.fillText(region.workplace_name, x1 + 5 + textPadding, y1 + 22);
}
});
}
// 지도 클릭 이벤트 처리
function handleMapClick(event) {
if (!mapCanvas || mapRegions.length === 0) return;
const rect = mapCanvas.getBoundingClientRect();
// CSS 스케일 보정: 캔버스의 논리적 크기와 화면 표시 크기가 다를 수 있음
const scaleX = mapCanvas.width / rect.width;
const scaleY = mapCanvas.height / rect.height;
const x = (event.clientX - rect.left) * scaleX;
const y = (event.clientY - rect.top) * scaleY;
// 클릭한 위치에 있는 영역 찾기
for (let i = mapRegions.length - 1; i >= 0; i--) {
const region = mapRegions[i];
const x1 = (region.x_start / 100) * mapCanvas.width;
const y1 = (region.y_start / 100) * mapCanvas.height;
const x2 = (region.x_end / 100) * mapCanvas.width;
const y2 = (region.y_end / 100) * mapCanvas.height;
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
// 영역 클릭됨
selectWorkplace(region.workplace_id, region.workplace_name);
// 지도 다시 그리기 (선택 효과 표시)
drawWorkplaceMap();
// 리스트에서도 동기화
syncWorkplaceListSelection(region.workplace_id);
return;
}
}
}
// 리스트 선택 동기화
function syncWorkplaceListSelection(workplaceId) {
// 리스트의 버튼들도 업데이트
document.querySelectorAll('[id^="workplace-"]').forEach(btn => {
if (btn.id === `workplace-${workplaceId}`) {
btn.classList.remove('btn-secondary');
btn.classList.add('btn-primary');
} else {
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
}
});
}
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;
// ==================== 기존 팀 구성 모달 (백업) ====================
// 팀 구성 모달 열기
// 팀 구성 수정 (TBM 수정 모달 열기)
async function openTeamCompositionModal(sessionId) {
currentSessionId = sessionId;
try {
// 세션 정보 로드
const session = await window.TbmAPI.getSession(sessionId);
if (!session) {
showToast('TBM 정보를 불러올 수 없습니다.', 'error');
return;
}
// 팀원 정보 로드
const teamMembers = await window.TbmAPI.getTeamMembers(sessionId);
if (!teamMembers) {
showToast('팀원 정보를 불러올 수 없습니다.', 'error');
return;
}
// workerTaskList 구성
workerTaskList = [];
const workerMap = new Map();
// 팀원별로 작업 그룹화
teamMembers.forEach(member => {
if (!workerMap.has(member.user_id)) {
workerMap.set(member.user_id, {
user_id: member.user_id,
worker_name: member.worker_name,
job_type: member.job_type,
tasks: []
});
}
workerMap.get(member.user_id).tasks.push({
task_line_id: generateUUID(),
project_id: member.project_id,
work_type_id: member.work_type_id,
task_id: member.task_id,
workplace_category_id: member.workplace_category_id,
workplace_id: member.workplace_id,
workplace_category_name: member.workplace_category_name,
workplace_name: member.workplace_name,
work_detail: member.work_detail,
is_present: member.is_present !== undefined ? member.is_present : true
});
});
workerTaskList = Array.from(workerMap.values());
// 모달 열기
document.getElementById('modalTitle').textContent = '팀 구성 수정';
document.getElementById('sessionId').value = sessionId;
document.getElementById('sessionDate').value = session.session_date;
// 입력자 표시
if (session.leader_name) {
document.getElementById('leaderName').value = `${session.leader_name} (${session.leader_job_type || ''})`;
document.getElementById('leaderId').value = session.leader_user_id;
} else if (session.created_by_name) {
document.getElementById('leaderName').value = `${session.created_by_name} (관리자)`;
document.getElementById('leaderId').value = '';
}
renderWorkerTaskList();
document.getElementById('tbmModal').style.display = 'flex';
lockBodyScroll();
} catch (error) {
console.error('❌ 팀 구성 로드 오류:', error);
showToast('팀 구성을 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.openTeamCompositionModal = openTeamCompositionModal;
// 선택된 작업자 업데이트
function updateSelectedWorkers() {
selectedWorkers.clear();
document.querySelectorAll('.worker-checkbox:checked').forEach(cb => {
selectedWorkers.add(parseInt(cb.dataset.userId));
});
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.user_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-user-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';
unlockBodyScroll();
}
window.closeTeamModal = closeTeamModal;
// 팀 구성 저장
async function saveTeamComposition() {
if (selectedWorkers.size === 0) {
showToast('최소 1명 이상의 작업자를 선택해주세요.', 'error');
return;
}
const members = Array.from(selectedWorkers).map(workerId => ({
user_id: workerId
}));
try {
const response = await window.TbmAPI.addTeamMembers(currentSessionId, 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 filteredData = await window.TbmAPI.getFilteredSafetyChecks(sessionId);
const { basic, weather, task, weatherInfo } = filteredData;
const categoryNames = {
'PPE': '개인 보호 장비',
'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경',
'EMERGENCY': '비상 대응',
'WEATHER': '날씨',
'TASK': '작업'
};
const weatherIcons = {
clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥',
cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷'
};
const container = document.getElementById('safetyChecklistContainer');
let html = '';
// 1. 기본 사항 섹션
if (basic && basic.length > 0) {
const basicGrouped = groupChecksByCategory(basic);
html += `
<div class="safety-section" style="margin-bottom: 1.5rem;">
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
<span>📋</span> 기본 안전 사항 (${basic.length}개)
</div>
${renderCategoryGroups(basicGrouped, categoryNames)}
</div>
`;
}
// 2. 날씨별 섹션
if (weather && weather.length > 0) {
const weatherConditions = weatherInfo?.weather_conditions || [];
const conditionNames = weatherConditions.map(c => {
const icon = weatherIcons[c] || '🌤️';
return `${icon} ${getWeatherConditionName(c)}`;
}).join(', ') || '맑음';
html += `
<div class="safety-section" style="margin-bottom: 1.5rem;">
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #f59e0b, #d97706); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
<span>🌤️</span> 오늘 날씨 관련 (${conditionNames}) - ${weather.length}
</div>
${renderCheckItems(weather)}
</div>
`;
}
// 3. 작업별 섹션
if (task && task.length > 0) {
const taskGrouped = groupChecksByTask(task);
html += `
<div class="safety-section" style="margin-bottom: 1.5rem;">
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #10b981, #059669); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
<span>🔧</span> 작업별 안전 사항 - ${task.length}
</div>
${renderTaskGroups(taskGrouped)}
</div>
`;
}
// 체크리스트가 없는 경우
if ((!basic || basic.length === 0) && (!weather || weather.length === 0) && (!task || task.length === 0)) {
html = `
<div style="text-align: center; padding: 2rem; color: #6b7280;">
<div style="font-size: 2rem; margin-bottom: 0.5rem;">📋</div>
<p>등록된 안전 체크 항목이 없습니다.</p>
</div>
`;
}
container.innerHTML = html;
document.getElementById('safetyModal').style.display = 'flex';
lockBodyScroll();
} catch (error) {
console.error('❌ 안전 체크 조회 오류:', error);
showToast('안전 체크 정보를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.openSafetyCheckModal = openSafetyCheckModal;
// 카테고리별 그룹화
function groupChecksByCategory(checks) {
return checks.reduce((acc, check) => {
const category = check.check_category || 'OTHER';
if (!acc[category]) acc[category] = [];
acc[category].push(check);
return acc;
}, {});
}
// 작업별 그룹화
function groupChecksByTask(checks) {
return checks.reduce((acc, check) => {
const taskId = check.task_id || 0;
const taskName = check.task_name || '기타 작업';
if (!acc[taskId]) acc[taskId] = { name: taskName, items: [] };
acc[taskId].items.push(check);
return acc;
}, {});
}
// 날씨 조건명 반환
function getWeatherConditionName(code) {
const names = {
clear: '맑음', rain: '비', snow: '눈', heat: '폭염',
cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지'
};
return names[code] || code;
}
// 카테고리 그룹 렌더링
function renderCategoryGroups(grouped, categoryNames) {
return Object.keys(grouped).map(category => `
<div style="margin-bottom: 1rem; background: white; border-radius: 0.5rem; border: 1px solid #e5e7eb; overflow: hidden;">
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; padding: 0.625rem 0.875rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb;">
${categoryNames[category] || category}
</div>
${renderCheckItems(grouped[category])}
</div>
`).join('');
}
// 작업 그룹 렌더링
function renderTaskGroups(grouped) {
return Object.values(grouped).map(group => `
<div style="margin-bottom: 1rem; background: white; border-radius: 0.5rem; border: 1px solid #e5e7eb; overflow: hidden;">
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; padding: 0.625rem 0.875rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb;">
📋 ${group.name}
</div>
${renderCheckItems(group.items)}
</div>
`).join('');
}
// 체크 항목 렌더링
function renderCheckItems(items) {
return items.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('');
}
// 안전 체크 모달 닫기
function closeSafetyModal() {
document.getElementById('safetyModal').style.display = 'none';
unlockBodyScroll();
}
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 {
await window.TbmAPI.saveSafetyChecks(currentSessionId, records);
showToast('안전 체크가 완료되었습니다.', 'success');
closeSafetyModal();
} catch (error) {
console.error('❌ 안전 체크 저장 오류:', error);
showToast('안전 체크 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveSafetyChecklist = saveSafetyChecklist;
// TBM 완료 모달용 팀원 데이터
let completeModalTeam = [];
// TBM 완료 모달 열기
async 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';
lockBodyScroll();
// 팀원 조회 → 근태 선택 렌더링
try {
completeModalTeam = await window.TbmAPI.getTeamMembers(sessionId);
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';
unlockBodyScroll();
}
window.closeCompleteModal = closeCompleteModal;
// TBM 세션 완료
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({
user_id: completeModalTeam[i].user_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, attendance_data: attendanceData }
);
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');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<span class="tbm-btn-icon">&#10003;</span> 완료'; }
}
}
window.completeTbmSession = completeTbmSession;
// TBM 세션 상세 보기
async function viewTbmSession(sessionId) {
try {
// 세션 정보, 팀 구성, 안전 체크 동시 조회
const [session, team, safety] = await Promise.all([
window.TbmAPI.getSession(sessionId),
window.TbmAPI.getTeamMembers(sessionId),
window.TbmAPI.getSafetyChecks(sessionId)
]);
if (!session) {
showToast('세션 정보를 불러올 수 없습니다.', 'error');
return;
}
// 기본 정보 표시
const leaderDisplay = session.leader_name || session.created_by_name || '-';
const dateDisplay = formatDate(session.session_date) || '-';
const statusMap = { draft: '진행중', completed: '완료', cancelled: '취소' };
const statusText = statusMap[session.status] || session.status;
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;">${escapeHtml(leaderDisplay)}</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;">${escapeHtml(dateDisplay)}</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;">${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;">팀원 (${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;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">프로젝트</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(session.project_name)}</div>
</div>
` : ''}
${session.work_location ? `
<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;">${escapeHtml(session.work_location)}</div>
</div>
` : ''}
`;
// 팀 구성 표시 (작업자별 작업 정보 포함)
const teamContainer = document.getElementById('detailTeamMembers');
if (team.length === 0) {
teamContainer.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">등록된 팀원이 없습니다.</p>';
} else {
// 작업자별로 그룹화
const workerMap = new Map();
team.forEach(member => {
if (!workerMap.has(member.user_id)) {
workerMap.set(member.user_id, {
worker_name: member.worker_name,
job_type: member.job_type,
is_present: member.is_present,
tasks: []
});
}
workerMap.get(member.user_id).tasks.push(member);
});
teamContainer.style.display = 'flex';
teamContainer.style.flexDirection = 'column';
teamContainer.style.gap = '0.75rem';
teamContainer.style.gridTemplateColumns = '';
teamContainer.innerHTML = Array.from(workerMap.values()).map(worker => `
<div style="border: 1px solid #e5e7eb; border-radius: 0.5rem; overflow: hidden;">
<div style="padding: 0.625rem 0.875rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; display: flex; align-items: center; justify-content: space-between;">
<div>
<span style="font-weight: 600;">${escapeHtml(worker.worker_name)}</span>
<span style="font-size: 0.75rem; opacity: 0.85; margin-left: 0.25rem;">${escapeHtml(worker.job_type || '')}</span>
</div>
${!worker.is_present ? '<span style="font-size: 0.75rem; background: rgba(239,68,68,0.8); padding: 0.125rem 0.5rem; border-radius: 4px;">결석</span>' : ''}
</div>
<div style="padding: 0.625rem 0.875rem;">
${worker.tasks.map(t => `
<div style="display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 0.375rem;">
${t.project_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 4px;">${escapeHtml(t.project_name)}</span>` : ''}
${t.work_type_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #fef3c7; color: #92400e; border-radius: 4px;">${escapeHtml(t.work_type_name)}</span>` : ''}
${t.task_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #dcfce7; color: #166534; border-radius: 4px;">${escapeHtml(t.task_name)}</span>` : ''}
${t.workplace_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #f1f5f9; color: #475569; border-radius: 4px;">${escapeHtml((t.workplace_category_name ? t.workplace_category_name + ' > ' : '') + t.workplace_name)}</span>` : ''}
</div>
`).join('')}
</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('');
}
// 푸터 버튼 동적 생성
const footer = document.getElementById('detailModalFooter');
const safeId = parseInt(session.session_id) || 0;
console.log('📋 TBM 상세 - session_id:', safeId, 'status:', session.status);
if (session.status === 'draft') {
footer.innerHTML = `
<button type="button" class="tbm-btn tbm-btn-danger" onclick="confirmDeleteTbm(${safeId})">
삭제
</button>
<button type="button" class="tbm-btn tbm-btn-primary" onclick="closeDetailModal(); openTeamCompositionModal(${safeId})">
수정
</button>
<button type="button" class="tbm-btn" style="background:#8b5cf6; color:white;" onclick="closeDetailModal(); openDesktopSplitModal(${safeId})">
분할
</button>
<button type="button" class="tbm-btn" style="background:#f59e0b; color:white;" onclick="closeDetailModal(); openDesktopPullModal(${safeId})">
빼오기
</button>
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
`;
} else {
footer.innerHTML = `
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
`;
}
document.getElementById('detailModal').style.display = 'flex';
lockBodyScroll();
} catch (error) {
console.error('❌ TBM 상세 조회 오류:', error);
showToast('상세 정보를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.viewTbmSession = viewTbmSession;
// TBM 삭제 확인
function confirmDeleteTbm(sessionId) {
if (!confirm('이 TBM을 삭제하시겠습니까?\n삭제 후 복구할 수 없습니다.')) return;
deleteTbmSession(sessionId);
}
window.confirmDeleteTbm = confirmDeleteTbm;
// TBM 세션 삭제 → TbmAPI 위임
async function deleteTbmSession(sessionId) {
try {
await window.TbmAPI.deleteSession(sessionId);
showToast('TBM이 삭제되었습니다.', 'success');
closeDetailModal();
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadRecentTbmGroupedByDate();
}
} catch (error) {
console.error('❌ TBM 삭제 오류:', error);
showToast(error?.message || 'TBM 삭제 중 오류가 발생했습니다.', 'error');
}
}
window.deleteTbmSession = deleteTbmSession;
// 상세보기 모달 닫기
function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none';
unlockBodyScroll();
}
window.closeDetailModal = closeDetailModal;
// 작업 인계 모달 열기
async function openHandoverModal(sessionId) {
currentSessionId = sessionId;
// 세션 정보와 팀 구성 조회
try {
const [session, team] = await Promise.all([
window.TbmAPI.getSession(sessionId),
window.TbmAPI.getTeamMembers(sessionId)
]);
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.user_id !== session.leader_user_id
);
toLeaderSelect.innerHTML = '<option value="">인수자 선택...</option>' +
otherLeaders.map(w => `
<option value="${w.user_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.user_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 = getTodayKST();
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';
lockBodyScroll();
} catch (error) {
console.error('❌ 인계 모달 열기 오류:', error);
showToast('인계 정보를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.openHandoverModal = openHandoverModal;
// 인계 모달 닫기
function closeHandoverModal() {
document.getElementById('handoverModal').style.display = 'none';
unlockBodyScroll();
}
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_user_id 가져오기)
const sessionData = await window.TbmAPI.getSession(sessionId);
const fromLeaderId = sessionData?.leader_user_id;
if (!fromLeaderId) {
showToast('세션 정보를 찾을 수 없습니다.', 'error');
return;
}
const handoverData = {
session_id: sessionId,
from_leader_user_id: fromLeaderId,
to_leader_user_id: toLeaderId,
handover_date: handoverDate,
handover_time: handoverTime,
reason: reason,
handover_notes: handoverNotes,
user_ids: workerIds
};
const response = await window.TbmAPI.saveHandover(handoverData);
if (response && response.success) {
showToast('작업 인계가 요청되었습니다.', 'success');
closeHandoverModal();
} else {
throw new Error(response.message || '인계 요청에 실패했습니다.');
}
} catch (error) {
console.error('❌ 작업 인계 저장 오류:', error);
showToast('작업 인계 중 오류가 발생했습니다.', 'error');
}
}
window.saveHandover = saveHandover;
// ==================== 데스크탑 분할 기능 ====================
let splitModalSessionId = null;
let splitModalTeam = [];
async function openDesktopSplitModal(sessionId) {
splitModalSessionId = sessionId;
try {
splitModalTeam = await window.TbmAPI.getTeamMembers(sessionId);
if (splitModalTeam.length === 0) {
showToast('팀원이 없습니다.', 'error'); return;
}
const modal = document.getElementById('splitModal');
if (!modal) { showToast('분할 모달을 찾을 수 없습니다.', 'error'); return; }
const list = document.getElementById('splitMemberList');
list.innerHTML = splitModalTeam.map((m, i) => {
const hours = m.work_hours != null ? parseFloat(m.work_hours) : 8;
return `
<div style="display:flex; align-items:center; justify-content:space-between; padding:0.5rem; border:1px solid #e5e7eb; border-radius:0.375rem;">
<div>
<strong>${escapeHtml(m.worker_name)}</strong>
<span style="font-size:0.75rem; color:#6b7280;">(${hours}h)</span>
</div>
<div style="display:flex; gap:0.25rem; align-items:center;">
<input type="number" id="split_hours_${i}" step="0.5" min="0.5" max="${hours - 0.5}" placeholder="분할시간" style="width:80px; padding:0.25rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.8125rem;">
<button type="button" class="tbm-btn tbm-btn-primary" style="padding:0.25rem 0.5rem; font-size:0.75rem;" onclick="executeSplit(${i})">분할</button>
</div>
</div>`;
}).join('');
modal.style.display = 'flex';
lockBodyScroll();
} catch(e) {
console.error('분할 모달 오류:', e);
showToast('팀원 조회 오류', 'error');
}
}
window.openDesktopSplitModal = openDesktopSplitModal;
async function executeSplit(memberIdx) {
const m = splitModalTeam[memberIdx];
const currentHours = m.work_hours != null ? parseFloat(m.work_hours) : 8;
const splitHours = parseFloat(document.getElementById(`split_hours_${memberIdx}`).value);
if (!splitHours || splitHours <= 0 || splitHours >= currentHours) {
showToast(`올바른 시간 입력 (0 < 시간 < ${currentHours})`, 'error'); return;
}
try {
await window.TbmAPI.updateTeamMember(splitModalSessionId, {
user_id: m.user_id, project_id: m.project_id, work_type_id: m.work_type_id,
task_id: m.task_id, workplace_category_id: m.workplace_category_id, workplace_id: m.workplace_id,
work_detail: m.work_detail, is_present: true, work_hours: splitHours
});
await window.TbmAPI.splitAssignment(splitModalSessionId, {
user_id: m.user_id, work_hours: currentHours - splitHours,
project_id: m.project_id, work_type_id: m.work_type_id
});
showToast(`${escapeHtml(m.worker_name)} 분할 완료: ${splitHours}h + ${currentHours - splitHours}h`, 'success');
closeSplitModal();
if (currentTab === 'tbm-input') await loadTodayOnlyTbm(); else await loadRecentTbmGroupedByDate();
} catch(e) {
console.error('분할 오류:', e);
showToast('분할 처리 중 오류', 'error');
}
}
window.executeSplit = executeSplit;
function closeSplitModal() {
const modal = document.getElementById('splitModal');
if (modal) modal.style.display = 'none';
unlockBodyScroll();
}
window.closeSplitModal = closeSplitModal;
// ==================== 데스크탑 빼오기 기능 ====================
let pullModalSessionId = null;
async function openDesktopPullModal(targetSessionId) {
pullModalSessionId = targetSessionId;
try {
const todayStr = getTodayKST();
const sessions = await window.TbmAPI.fetchSessionsByDate(todayStr);
const otherSessions = sessions.filter(s => s.session_id !== targetSessionId && s.status === 'draft');
const modal = document.getElementById('pullModal');
if (!modal) { showToast('빼오기 모달을 찾을 수 없습니다.', 'error'); return; }
const list = document.getElementById('pullSessionList');
if (otherSessions.length === 0) {
list.innerHTML = '<div style="padding:1rem; text-align:center; color:#9ca3af;">빼올 수 있는 다른 TBM이 없습니다.</div>';
} else {
list.innerHTML = otherSessions.map(s => {
const leader = escapeHtml(s.leader_name || s.created_by_name || '미지정');
const count = parseInt(s.team_member_count) || 0;
return `
<div style="border:1px solid #e5e7eb; border-radius:0.375rem; margin-bottom:0.5rem;">
<div style="padding:0.5rem 0.75rem; cursor:pointer; display:flex; justify-content:space-between; align-items:center;" onclick="togglePullSessionMembers(${s.session_id}, this)">
<div><strong>${leader}</strong> <span style="font-size:0.75rem; color:#6b7280;">(${count}명)</span></div>
<span style="font-size:0.75rem; color:#6b7280;">▼</span>
</div>
<div id="pullMembers_${s.session_id}" style="display:none; padding:0.5rem; border-top:1px solid #f3f4f6;"></div>
</div>`;
}).join('');
}
modal.style.display = 'flex';
lockBodyScroll();
} catch(e) {
console.error('빼오기 모달 오류:', e);
showToast('빼오기 데이터 로드 오류', 'error');
}
}
window.openDesktopPullModal = openDesktopPullModal;
async function togglePullSessionMembers(sessionId, el) {
const container = document.getElementById(`pullMembers_${sessionId}`);
if (container.style.display !== 'none') {
container.style.display = 'none'; return;
}
try {
const members = await window.TbmAPI.getTeamMembers(sessionId);
container.innerHTML = members.map(m => {
const hours = m.work_hours != null ? parseFloat(m.work_hours) : 8;
return `
<div style="display:flex; align-items:center; justify-content:space-between; padding:0.375rem 0; border-bottom:1px solid #f9fafb;">
<span>${escapeHtml(m.worker_name)} <span style="font-size:0.75rem; color:#6b7280;">(${hours}h)</span></span>
<div style="display:flex; gap:0.25rem; align-items:center;">
<input type="number" id="pull_h_${sessionId}_${m.user_id}" step="0.5" min="0.5" max="${hours}" value="${hours}" style="width:60px; padding:0.25rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.75rem;">
<button type="button" class="tbm-btn tbm-btn-primary" style="padding:0.25rem 0.5rem; font-size:0.75rem;" onclick="executePull(${sessionId}, ${m.user_id}, '${escapeHtml(m.worker_name)}')">빼오기</button>
</div>
</div>`;
}).join('') || '<div style="color:#9ca3af; padding:0.25rem;">팀원 없음</div>';
container.style.display = 'block';
} catch(e) {
container.innerHTML = '<div style="color:#ef4444; padding:0.25rem;">로드 오류</div>';
container.style.display = 'block';
}
}
window.togglePullSessionMembers = togglePullSessionMembers;
async function executePull(sourceSessionId, workerId, workerName) {
const hoursInput = document.getElementById(`pull_h_${sourceSessionId}_${workerId}`);
const hours = parseFloat(hoursInput?.value);
if (!hours || hours <= 0) { showToast('시간을 입력하세요', 'error'); return; }
try {
const res = await window.TbmAPI.transfer({
transfer_type: 'pull',
user_id: workerId,
source_session_id: sourceSessionId,
dest_session_id: pullModalSessionId,
hours: hours
});
showToast(`${workerName} ${hours}h 빼오기 완료` + (res.data?.warning ? ` (${res.data.warning})` : ''), 'success');
closePullModal();
if (currentTab === 'tbm-input') await loadTodayOnlyTbm(); else await loadRecentTbmGroupedByDate();
} catch(e) {
console.error('빼오기 오류:', e);
showToast(e.message || '빼오기 처리 오류', 'error');
}
}
window.executePull = executePull;
function closePullModal() {
const modal = document.getElementById('pullModal');
if (modal) modal.style.display = 'none';
unlockBodyScroll();
}
window.closePullModal = closePullModal;
// showToast → api-base.js 전역 사용