Files
TK-FB-Project/web-ui/js/tbm.js
Hyungi Ahn b6485e3140 feat: 대시보드 작업장 현황 지도 구현
- 실시간 작업장 현황을 지도로 시각화
- 작업장 관리 페이지에서 정의한 구역 정보 활용
- TBM 작업자 및 방문자 현황 표시

주요 변경사항:
- dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거)
- workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현
- modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가

시각화 방식:
- 인원 없음: 회색 테두리 + 작업장 이름
- 내부 작업자: 파란색 영역 + 인원 수
- 외부 방문자: 보라색 영역 + 인원 수
- 둘 다: 초록색 영역 + 총 인원 수

기술 구현:
- Canvas API 기반 사각형 영역 렌더링
- map-regions API를 통한 데이터 일관성 보장
- 클릭 이벤트로 상세 정보 모달 표시

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 15:46:47 +09:00

2316 lines
82 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// 전역 변수
let allSessions = [];
let todaySessions = [];
let allWorkers = [];
let allProjects = [];
let allWorkTypes = [];
let allTasks = [];
let allSafetyChecks = [];
let allWorkplaces = [];
let allWorkplaceCategories = [];
let currentUser = null;
let currentSessionId = null;
let selectedWorkers = new Set();
let currentTab = 'tbm-input';
// 새로운 TBM 입력 방식 관련 변수
let workerTaskList = []; // [{worker_id, worker_name, job_type, tasks: [{task_line_id, project_id, ...}]}]
let selectedWorkersInModal = new Set(); // 모달에서 선택된 작업자 ID 세트
let currentEditingTaskLine = null; // 현재 편집 중인 작업 라인 정보 {workerIndex, taskIndex}
let selectedCategory = null;
let selectedWorkplace = null;
let selectedCategoryName = '';
let selectedWorkplaceName = '';
let isBulkMode = false; // 일괄 설정 모드인지 여부
let bulkSelectedWorkers = new Set(); // 일괄 설정에서 선택된 작업자 인덱스
// ==================== 유틸리티 함수 ====================
/**
* 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환
*/
function getTodayKST() {
const now = new Date();
// 한국 시간대로 변환 (UTC+9)
const kstOffset = 9 * 60; // 9시간을 분 단위로
const utc = now.getTime() + (now.getTimezoneOffset() * 60000); // UTC 시간
const kstTime = new Date(utc + (kstOffset * 60000)); // KST 시간
const year = kstTime.getFullYear();
const month = String(kstTime.getMonth() + 1).padStart(2, '0');
const day = String(kstTime.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* ISO 날짜 문자열을 YYYY-MM-DD 형식으로 변환
* @param {string} dateString - ISO 형식 날짜 문자열 또는 YYYY-MM-DD 형식
* @returns {string} YYYY-MM-DD 형식 날짜
*/
function formatDate(dateString) {
if (!dateString) return '';
// 이미 YYYY-MM-DD 형식이면 그대로 반환
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return dateString;
}
// ISO 형식 또는 다른 형식이면 변환
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// ==================== 페이지 초기화 ====================
// 페이지 초기화
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();
document.getElementById('tbmDate').value = today;
document.getElementById('sessionDate').value = today;
// 이벤트 리스너 설정
setupEventListeners();
// 초기 데이터 로드
await loadInitialData();
await loadTodayOnlyTbm();
});
// 이벤트 리스너 설정
function setupEventListeners() {
const tbmDateInput = document.getElementById('tbmDate');
if (tbmDateInput) {
tbmDateInput.addEventListener('change', () => {
const date = tbmDateInput.value;
loadTbmSessionsByDate(date);
});
}
}
// 초기 데이터 로드
async function loadInitialData() {
try {
// 현재 로그인한 사용자 정보 가져오기
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
currentUser = userInfo;
console.log('👤 로그인 사용자:', currentUser);
// 작업자 목록 로드
const workersResponse = await window.apiCall('/workers?limit=1000');
if (workersResponse) {
allWorkers = Array.isArray(workersResponse) ? workersResponse : (workersResponse.data || []);
// 활성 상태인 작업자만 필터링
allWorkers = allWorkers.filter(w => w.status === 'active' && w.employment_status === 'employed');
console.log('✅ 작업자 목록 로드:', allWorkers.length + '명');
}
// 프로젝트 목록 로드 (활성 프로젝트만)
const projectsResponse = await window.apiCall('/projects?is_active=1');
if (projectsResponse) {
const projects = Array.isArray(projectsResponse) ? projectsResponse : (projectsResponse.data || []);
// 활성 프로젝트만 필터링 (is_active가 1 또는 true인 경우)
allProjects = projects.filter(p => p.is_active === 1 || p.is_active === true || p.is_active === '1');
console.log('✅ 프로젝트 목록 로드:', allProjects.length + '개 (활성)');
populateProjectSelect();
}
// 안전 체크리스트 로드
const safetyResponse = await window.apiCall('/tbm/safety-checks');
if (safetyResponse && safetyResponse.success) {
allSafetyChecks = safetyResponse.data;
console.log('✅ 안전 체크리스트 로드:', allSafetyChecks.length + '개');
}
// 공정(Work Types) 목록 로드
const workTypesResponse = await window.apiCall('/daily-work-reports/work-types');
if (workTypesResponse && workTypesResponse.success) {
allWorkTypes = workTypesResponse.data || [];
console.log('✅ 공정 목록 로드:', allWorkTypes.length + '개');
}
// 작업(Tasks) 목록 로드
const tasksResponse = await window.apiCall('/tasks/active/list');
if (tasksResponse && tasksResponse.success) {
allTasks = tasksResponse.data || [];
console.log('✅ 작업 목록 로드:', allTasks.length + '개');
}
// 작업장 목록 로드
const workplacesResponse = await window.apiCall('/workplaces?is_active=true');
if (workplacesResponse && workplacesResponse.success) {
allWorkplaces = workplacesResponse.data || [];
console.log('✅ 작업장 목록 로드:', allWorkplaces.length + '개');
}
// 작업장 카테고리 로드
const categoriesResponse = await window.apiCall('/workplaces/categories/active/list');
if (categoriesResponse && categoriesResponse.success) {
allWorkplaceCategories = categoriesResponse.data || [];
console.log('✅ 작업장 카테고리 로드:', allWorkplaceCategories.length + '개');
}
} catch (error) {
console.error('❌ 초기 데이터 로드 오류:', error);
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
// ==================== 탭 전환 ====================
// 탭 전환
function switchTbmTab(tabName) {
currentTab = tabName;
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.tab-btn').forEach(btn => {
if (btn.dataset.tab === tabName) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// 탭 컨텐츠 표시 변경
document.querySelectorAll('.code-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tabName}-tab`).classList.add('active');
// 탭에 따라 데이터 로드
if (tabName === 'tbm-input') {
loadTodayOnlyTbm();
} else if (tabName === 'tbm-manage') {
const tbmDate = document.getElementById('tbmDate');
if (tbmDate && tbmDate.value) {
loadTbmSessionsByDate(tbmDate.value);
} else {
loadTodayTbm();
}
}
}
window.switchTbmTab = switchTbmTab;
// ==================== TBM 입력 탭 ====================
// 오늘의 TBM만 로드 (TBM 입력 탭용)
async function loadTodayOnlyTbm() {
const today = getTodayKST();
try {
const response = await window.apiCall(`/tbm/sessions/date/${today}`);
if (response && response.success) {
todaySessions = response.data || [];
displayTodayTbmSessions();
} else {
todaySessions = [];
displayTodayTbmSessions();
}
} catch (error) {
console.error('❌ 오늘 TBM 조회 오류:', error);
showToast('오늘 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
todaySessions = [];
displayTodayTbmSessions();
}
}
window.loadTodayOnlyTbm = loadTodayOnlyTbm;
// 오늘의 TBM 세션 표시
function displayTodayTbmSessions() {
const grid = document.getElementById('todayTbmGrid');
const emptyState = document.getElementById('todayEmptyState');
const todayTotalEl = document.getElementById('todayTotalSessions');
const todayCompletedEl = document.getElementById('todayCompletedSessions');
const todayActiveEl = document.getElementById('todayActiveSessions');
if (todaySessions.length === 0) {
grid.innerHTML = '';
emptyState.style.display = 'flex';
todayTotalEl.textContent = '0';
todayCompletedEl.textContent = '0';
todayActiveEl.textContent = '0';
return;
}
emptyState.style.display = 'none';
const completedCount = todaySessions.filter(s => s.status === 'completed').length;
const activeCount = todaySessions.filter(s => s.status === 'draft').length;
todayTotalEl.textContent = todaySessions.length;
todayCompletedEl.textContent = completedCount;
todayActiveEl.textContent = activeCount;
grid.innerHTML = todaySessions.map(session => createSessionCard(session)).join('');
}
// ==================== TBM 관리 탭 ====================
// 오늘 TBM 로드 (TBM 관리 탭용)
async function loadTodayTbm() {
const today = getTodayKST();
document.getElementById('tbmDate').value = today;
await loadTbmSessionsByDate(today);
}
window.loadTodayTbm = loadTodayTbm;
// 전체 TBM 로드
async function loadAllTbm() {
try {
const response = await window.apiCall('/tbm/sessions');
if (response && response.success) {
allSessions = response.data || [];
document.getElementById('tbmDate').value = '';
displayTbmSessions();
} else {
allSessions = [];
displayTbmSessions();
}
} catch (error) {
console.error('❌ 전체 TBM 조회 오류:', error);
showToast('전체 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
allSessions = [];
displayTbmSessions();
}
}
window.loadAllTbm = loadAllTbm;
// 특정 날짜의 TBM 세션 목록 로드
async function loadTbmSessionsByDate(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
if (response && response.success) {
allSessions = response.data || [];
displayTbmSessions();
} else {
allSessions = [];
displayTbmSessions();
}
} catch (error) {
console.error('❌ TBM 세션 조회 오류:', error);
showToast('TBM 세션을 불러오는 중 오류가 발생했습니다.', 'error');
allSessions = [];
displayTbmSessions();
}
}
// TBM 세션 목록 표시 (관리 탭용)
function displayTbmSessions() {
const grid = document.getElementById('tbmSessionsGrid');
const emptyState = document.getElementById('emptyState');
const totalSessionsEl = document.getElementById('totalSessions');
const completedSessionsEl = document.getElementById('completedSessions');
if (allSessions.length === 0) {
grid.innerHTML = '';
emptyState.style.display = 'flex';
totalSessionsEl.textContent = '0';
completedSessionsEl.textContent = '0';
return;
}
emptyState.style.display = 'none';
const completedCount = allSessions.filter(s => s.status === 'completed').length;
totalSessionsEl.textContent = allSessions.length;
completedSessionsEl.textContent = completedCount;
grid.innerHTML = allSessions.map(session => createSessionCard(session)).join('');
}
// TBM 세션 카드 생성 (공통)
function createSessionCard(session) {
const statusBadge = {
'draft': '<span class="badge" style="background: #fef3c7; color: #92400e;">진행중</span>',
'completed': '<span class="badge" style="background: #dcfce7; color: #166534;">완료</span>',
'cancelled': '<span class="badge" style="background: #fee2e2; color: #991b1b;">취소</span>'
}[session.status] || '';
// 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시)
const leaderDisplay = session.leader_name
? `${session.leader_name} (${session.leader_job_type || '작업자'})`
: `${session.created_by_name || '작업 책임자'} (관리자)`;
return `
<div class="project-card" style="cursor: pointer;" onclick="viewTbmSession(${session.session_id})">
<div class="project-header">
<div>
<h3 class="project-name" style="font-size: 1rem; margin-bottom: 0.25rem;">
${leaderDisplay}
</h3>
<p style="font-size: 0.75rem; color: #6b7280; margin: 0;">
${formatDate(session.session_date)}
</p>
</div>
${statusBadge}
</div>
<div class="project-info" style="margin-top: 1rem;">
<div class="info-item">
<span class="info-label">프로젝트</span>
<span class="info-value">${session.project_name || '-'}</span>
</div>
<div class="info-item">
<span class="info-label">공정</span>
<span class="info-value">${session.work_type_name || '-'}</span>
</div>
<div class="info-item">
<span class="info-label">작업</span>
<span class="info-value">${session.task_name || '-'}</span>
</div>
<div class="info-item">
<span class="info-label">작업 장소</span>
<span class="info-value">${session.work_location || '-'}</span>
</div>
<div class="info-item">
<span class="info-label">팀원 수</span>
<span class="info-value">${session.team_member_count || 0}명</span>
</div>
<div class="info-item">
<span class="info-label">시작 시간</span>
<span class="info-value">${session.start_time || '-'}</span>
</div>
</div>
${session.work_description ? `
<div style="margin-top: 0.75rem; padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; font-size: 0.875rem; color: #374151;">
${session.work_description}
</div>
` : ''}
<div style="margin-top: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
${session.status === 'draft' ? `
<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})" style="flex: 1; min-width: 100px;">
👥 팀 구성
</button>
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})" style="flex: 1; min-width: 100px;">
✅ 안전 체크
</button>
` : ''}
</div>
</div>
`;
}
// 새 TBM 모달 열기
function openNewTbmModal() {
currentSessionId = null;
workerTaskList = []; // 작업자 목록 초기화
document.getElementById('modalTitle').textContent = '새 TBM 시작';
document.getElementById('sessionId').value = '';
document.getElementById('tbmForm').reset();
const today = getTodayKST();
document.getElementById('sessionDate').value = today;
// 입력자 자동 설정 (readonly)
if (currentUser && currentUser.worker_id) {
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
if (worker) {
document.getElementById('leaderName').value = `${worker.worker_name} (${worker.job_type || ''})`;
document.getElementById('leaderId').value = worker.worker_id;
}
} else if (currentUser && currentUser.name) {
// 관리자: 관리자로 표시
document.getElementById('leaderName').value = `${currentUser.name} (관리자)`;
document.getElementById('leaderId').value = '';
}
// 작업자 목록 UI 초기화
renderWorkerTaskList();
document.getElementById('tbmModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
window.openNewTbmModal = openNewTbmModal;
// 입력자 선택 드롭다운 채우기
function populateLeaderSelect() {
const leaderSelect = document.getElementById('leaderId');
if (!leaderSelect) return;
// 로그인한 사용자가 작업자와 연결되어 있는지 확인
if (currentUser && currentUser.worker_id) {
// 작업자와 연결된 경우: 자동으로 선택하고 비활성화
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
if (worker) {
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name} (${worker.job_type || ''})</option>`;
leaderSelect.disabled = true;
console.log('✅ 입력자 자동 설정:', worker.worker_name);
} else {
// 작업자를 찾을 수 없는 경우
leaderSelect.innerHTML = '<option value="">입력자를 찾을 수 없습니다</option>';
leaderSelect.disabled = true;
}
} else {
// 관리자 계정 (worker_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 => `
<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</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="${p.project_id}">${p.project_name} (${p.job_no})</option>
`).join('');
}
// 공정(Work Type) 선택 드롭다운 채우기
function populateWorkTypeSelect() {
const workTypeSelect = document.getElementById('workTypeId');
if (!workTypeSelect) return;
workTypeSelect.innerHTML = '<option value="">공정 선택...</option>' +
allWorkTypes.map(wt => `
<option value="${wt.id}">${wt.name}${wt.category ? ' (' + wt.category + ')' : ''}</option>
`).join('');
}
// 작업장 선택 드롭다운 채우기
function populateWorkplaceSelect() {
const workLocationSelect = document.getElementById('workLocation');
if (!workLocationSelect) return;
workLocationSelect.innerHTML = '<option value="">작업장 선택...</option>' +
allWorkplaces.map(wp => `
<option value="${wp.workplace_name}">${wp.workplace_name}${wp.location ? ' - ' + 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="${task.task_id}">${task.task_name}</option>
`).join('');
if (filteredTasks.length === 0) {
taskSelect.innerHTML = '<option value="">등록된 작업이 없습니다</option>';
taskSelect.disabled = true;
}
}
window.loadTasksByWorkType = loadTasksByWorkType;
// TBM 모달 닫기
function closeTbmModal() {
document.getElementById('tbmModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeTbmModal = closeTbmModal;
// TBM 세션 저장 (작업자별 상세 정보 포함)
async function saveTbmSession() {
console.log('💾 TBM 저장 시작...');
let leaderId = parseInt(document.getElementById('leaderId').value);
// 관리자 계정인 경우 leader_id를 null로 설정
if (!leaderId || isNaN(leaderId)) {
if (!currentUser.worker_id) {
console.log('📝 관리자 계정: leader_id를 NULL로 설정');
leaderId = null;
} else {
console.error('❌ 입력자 설정 오류');
showToast('입력자 정보가 올바르지 않습니다.', 'error');
return;
}
}
const sessionData = {
session_date: document.getElementById('sessionDate').value,
leader_id: leaderId
};
console.log('📅 세션 데이터:', sessionData);
console.log('👥 작업자 리스트:', workerTaskList);
console.log('👤 현재 사용자:', currentUser);
if (!sessionData.session_date) {
console.error('❌ 날짜 누락');
showToast('TBM 날짜를 확인해주세요.', 'error');
return;
}
if (workerTaskList.length === 0) {
console.error('❌ 작업자 리스트가 비어있음');
showToast('최소 1명 이상의 작업자를 추가해주세요.', 'error');
return;
}
// 필수 항목 검증 (공정, 작업, 작업장)
let hasError = false;
for (const workerData of workerTaskList) {
for (const taskLine of workerData.tasks) {
if (!taskLine.work_type_id || !taskLine.task_id || !taskLine.workplace_id) {
showToast(`${workerData.worker_name}의 공정, 작업, 작업장을 모두 선택해주세요.`, 'error');
hasError = true;
break;
}
}
if (hasError) break;
}
if (hasError) return;
// 작업자-작업 데이터를 평평하게 변환
const members = [];
for (const workerData of workerTaskList) {
for (const taskLine of workerData.tasks) {
members.push({
worker_id: workerData.worker_id,
project_id: taskLine.project_id || null,
work_type_id: taskLine.work_type_id,
task_id: taskLine.task_id,
workplace_category_id: taskLine.workplace_category_id || null,
workplace_id: taskLine.workplace_id,
work_detail: taskLine.work_detail || null,
is_present: taskLine.is_present !== undefined ? taskLine.is_present : true
});
}
}
console.log('📤 전송할 팀 데이터:', members);
try {
const editingSessionId = document.getElementById('sessionId').value;
if (editingSessionId) {
// 수정 모드: 기존 팀원 삭제 후 재등록
console.log('📝 TBM 수정 모드:', editingSessionId);
// 기존 팀원 삭제
await window.apiCall(`/tbm/sessions/${editingSessionId}/team/clear`, 'DELETE');
// 새 팀원 일괄 추가
const teamResponse = await window.apiCall(
`/tbm/sessions/${editingSessionId}/team/batch`,
'POST',
{ members }
);
if (teamResponse && teamResponse.success) {
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length}건)`, 'success');
closeTbmModal();
// 목록 새로고침
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadTbmSessionsByDate(sessionData.session_date);
}
} else {
throw new Error(teamResponse.message || '팀원 수정에 실패했습니다.');
}
} else {
// 생성 모드: 새 TBM 세션 생성
console.log('✨ TBM 생성 모드');
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (response && response.success) {
const createdSessionId = response.data.session_id;
console.log('✅ TBM 세션 생성 완료:', createdSessionId);
// 작업자 일괄 추가
const teamResponse = await window.apiCall(
`/tbm/sessions/${createdSessionId}/team/batch`,
'POST',
{ members }
);
if (teamResponse && teamResponse.success) {
showToast(`TBM이 생성되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length}건)`, 'success');
closeTbmModal();
// 목록 새로고침
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadTbmSessionsByDate(sessionData.session_date);
}
} else {
throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.');
}
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
}
} catch (error) {
console.error('❌ TBM 세션 저장 오류:', error);
showToast('TBM 세션 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveTbmSession = saveTbmSession;
// ==================== 작업자 관리 ====================
// UUID 생성 함수
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// 작업자 카드 리스트 렌더링
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;">👤 ${workerData.worker_name}</span>
<span style="padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem;">
${workerData.job_type || '작업자'}
</span>
</div>
<button type="button" onclick="removeWorkerFromList(${workerIndex})" 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(${workerIndex})" 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 projectText = project ? project.project_name : '프로젝트 선택';
const workTypeText = workType ? workType.name : '공정 선택 *';
const taskText = task ? task.task_name : '작업 선택 *';
const workplaceText = taskLine.workplace_name
? `${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 style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-bottom: 0.5rem;">
<!-- 프로젝트 선택 -->
<button type="button"
onclick="openItemSelect('project', ${workerIndex}, ${taskIndex})"
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(${workerIndex}, ${taskIndex})"
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', ${workerIndex}, ${taskIndex})"
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', ${workerIndex}, ${taskIndex})"
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(${workerIndex}, ${taskIndex})"
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.worker_id));
workerCardGrid.innerHTML = allWorkers.map(worker => {
const isAdded = addedWorkerIds.has(worker.worker_id);
return `
<div id="worker-card-${worker.worker_id}"
onclick="toggleWorkerSelection(${worker.worker_id})"
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;">${worker.worker_name}</span>
</div>
<div style="font-size: 0.8rem; color: #6b7280;">
${worker.job_type || '작업자'}${worker.department ? ' · ' + 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';
}
window.openWorkerSelectionModal = openWorkerSelectionModal;
// 작업자 선택 토글
function toggleWorkerSelection(workerId) {
// 이미 추가된 작업자는 선택 불가
const alreadyAdded = workerTaskList.some(w => w.worker_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.worker_id));
allWorkers.forEach(worker => {
if (!addedWorkerIds.has(worker.worker_id)) {
selectedWorkersInModal.add(worker.worker_id);
const card = document.getElementById(`worker-card-${worker.worker_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.worker_id === workerId);
if (worker) {
workerTaskList.push({
worker_id: worker.worker_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';
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';
}
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';
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';
}
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';
}
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';
}
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';
currentEditingTaskLine = null;
}
window.closeItemSelectModal = closeItemSelectModal;
// ==================== 작업장 2단계 선택 ====================
// 작업장 선택 모달 열기 (작업 라인용)
async function openWorkplaceSelect(workerIndex, taskIndex) {
currentEditingTaskLine = { workerIndex, taskIndex };
await loadWorkplaceCategories();
document.getElementById('workplaceSelectModal').style.display = 'flex';
}
window.openWorkplaceSelect = openWorkplaceSelect;
// 작업장 선택 모달 닫기
function closeWorkplaceSelectModal() {
document.getElementById('workplaceSelectModal').style.display = 'none';
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);
// 지도 또는 리스트 로드
if (category && category.layout_image) {
// 지도가 있는 경우 - 지도 영역 표시
await loadWorkplaceMap(categoryId, category.layout_image);
document.getElementById('layoutMapArea').style.display = 'block';
} else {
// 지도가 없는 경우 - 리스트만 표시
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('toggleListBtn').style.display = 'none';
}
// 해당 카테고리의 작업장 리스트 로드 (오류 대비용)
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 response = await window.apiCall(`/workplaces?category_id=${categoryId}`);
if (!response || !response.success || !response.data || response.data.length === 0) {
workplaceList.innerHTML = '<div style="color: #9ca3af; text-align: center; padding: 2rem;">등록된 작업장이 없습니다</div>';
return;
}
const workplaces = response.data;
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 list = document.getElementById('workplaceList');
const icon = document.getElementById('toggleListIcon');
const btn = document.getElementById('toggleListBtn');
if (list.style.display === 'none' || list.style.display === '') {
list.style.display = 'flex';
icon.textContent = '▲';
btn.textContent = ' 리스트 닫기';
btn.insertBefore(icon, btn.firstChild);
} else {
list.style.display = 'none';
icon.textContent = '▼';
btn.textContent = ' 리스트 보기';
btn.insertBefore(icon, btn.firstChild);
}
}
window.toggleWorkplaceList = toggleWorkplaceList;
// 작업장 지도 로드 및 렌더링
let mapRegions = []; // 현재 로드된 지도 영역들
let mapCanvas = null;
let mapCtx = null;
let mapImage = null;
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:20005';
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
const fullImageUrl = layoutImagePath.startsWith('http')
? layoutImagePath
: `${apiBaseUrl}${layoutImagePath}`;
console.log('🖼️ 이미지 로드 시도:', fullImageUrl);
// 지도 영역 데이터 로드
const regionsResponse = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`);
if (regionsResponse && regionsResponse.success) {
mapRegions = regionsResponse.data || [];
} else {
mapRegions = [];
}
// 이미지 로드
mapImage = new Image();
mapImage.crossOrigin = 'anonymous';
mapImage.onload = function() {
// 캔버스 크기 설정 (최대 너비 800px)
const maxWidth = 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('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();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// 클릭한 위치에 있는 영역 찾기
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;
// ==================== 기존 팀 구성 모달 (백업) ====================
// 팀 구성 모달 열기
// 팀 구성 수정 (TBM 수정 모달 열기)
async function openTeamCompositionModal(sessionId) {
currentSessionId = sessionId;
try {
// 세션 정보 로드
const sessionResponse = await window.apiCall(`/tbm/sessions/${sessionId}`);
if (!sessionResponse || !sessionResponse.success) {
showToast('TBM 정보를 불러올 수 없습니다.', 'error');
return;
}
const session = sessionResponse.data; // data는 이미 객체
// 팀원 정보 로드
const teamResponse = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
if (!teamResponse || !teamResponse.success) {
showToast('팀원 정보를 불러올 수 없습니다.', 'error');
return;
}
const teamMembers = teamResponse.data;
// workerTaskList 구성
workerTaskList = [];
const workerMap = new Map();
// 팀원별로 작업 그룹화
teamMembers.forEach(member => {
if (!workerMap.has(member.worker_id)) {
workerMap.set(member.worker_id, {
worker_id: member.worker_id,
worker_name: member.worker_name,
job_type: member.job_type,
tasks: []
});
}
workerMap.get(member.worker_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_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';
document.body.style.overflow = 'hidden';
} 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.workerId));
});
const selectedCount = document.getElementById('selectedCount');
const selectedList = document.getElementById('selectedWorkersList');
selectedCount.textContent = selectedWorkers.size;
if (selectedWorkers.size === 0) {
selectedList.innerHTML = '<p style="margin: 0; color: #9ca3af; font-size: 0.875rem;">작업자를 선택해주세요</p>';
} else {
const selectedWorkersArray = Array.from(selectedWorkers).map(id => {
const worker = allWorkers.find(w => w.worker_id === id);
return worker ? `
<span style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.75rem; background: #3b82f6; color: white; border-radius: 9999px; font-size: 0.875rem;">
${worker.worker_name}
<button onclick="removeWorker(${id})" style="background: none; border: none; color: white; cursor: pointer; padding: 0; margin-left: 0.25rem; font-size: 1rem; line-height: 1;">×</button>
</span>
` : '';
});
selectedList.innerHTML = selectedWorkersArray.join('');
}
}
window.updateSelectedWorkers = updateSelectedWorkers;
// 작업자 제거
function removeWorker(workerId) {
const checkbox = document.querySelector(`.worker-checkbox[data-worker-id="${workerId}"]`);
if (checkbox) {
checkbox.checked = false;
updateSelectedWorkers();
}
}
window.removeWorker = removeWorker;
// 전체 선택
function selectAllWorkers() {
document.querySelectorAll('.worker-checkbox').forEach(cb => {
cb.checked = true;
});
updateSelectedWorkers();
}
window.selectAllWorkers = selectAllWorkers;
// 전체 해제
function deselectAllWorkers() {
document.querySelectorAll('.worker-checkbox').forEach(cb => {
cb.checked = false;
});
updateSelectedWorkers();
}
window.deselectAllWorkers = deselectAllWorkers;
// 팀 구성 모달 닫기
function closeTeamModal() {
document.getElementById('teamModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeTeamModal = closeTeamModal;
// 팀 구성 저장
async function saveTeamComposition() {
if (selectedWorkers.size === 0) {
showToast('최소 1명 이상의 작업자를 선택해주세요.', 'error');
return;
}
const members = Array.from(selectedWorkers).map(workerId => ({
worker_id: workerId
}));
try {
const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/team/batch`,
'POST',
{ members }
);
if (response && response.success) {
showToast(`${selectedWorkers.size}명의 팀원이 추가되었습니다.`, 'success');
closeTeamModal();
// 목록 새로고침
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
const date = document.getElementById('tbmDate').value;
await loadTbmSessionsByDate(date);
}
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('❌ 팀 구성 저장 오류:', error);
showToast('팀 구성 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveTeamComposition = saveTeamComposition;
// 안전 체크 모달 열기
async function openSafetyCheckModal(sessionId) {
currentSessionId = sessionId;
// 기존 안전 체크 기록 로드
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`);
const existingRecords = response && response.success ? response.data : [];
// 카테고리별로 그룹화
const grouped = {};
allSafetyChecks.forEach(check => {
if (!grouped[check.check_category]) {
grouped[check.check_category] = [];
}
const existingRecord = existingRecords.find(r => r.check_id === check.check_id);
grouped[check.check_category].push({
...check,
is_checked: existingRecord ? existingRecord.is_checked : false,
notes: existingRecord ? existingRecord.notes : ''
});
});
const categoryNames = {
'PPE': '개인 보호 장비',
'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경',
'EMERGENCY': '비상 대응'
};
const container = document.getElementById('safetyChecklistContainer');
container.innerHTML = Object.keys(grouped).map(category => `
<div style="margin-bottom: 1.5rem;">
<div style="font-weight: 600; font-size: 0.9375rem; color: #374151; padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; margin-bottom: 0.5rem;">
${categoryNames[category] || category}
</div>
${grouped[category].map(check => `
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6;">
<label style="display: flex; align-items: start; gap: 0.75rem; cursor: pointer;">
<input type="checkbox"
class="safety-check"
data-check-id="${check.check_id}"
${check.is_checked ? 'checked' : ''}
${check.is_required ? 'required' : ''}
style="width: 18px; height: 18px; margin-top: 0.125rem; cursor: pointer;">
<div style="flex: 1;">
<div style="font-weight: 500; color: #111827;">
${check.check_item}
${check.is_required ? '<span style="color: #ef4444;">*</span>' : ''}
</div>
${check.description ? `<div style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">${check.description}</div>` : ''}
</div>
</label>
</div>
`).join('')}
</div>
`).join('');
document.getElementById('safetyModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
} catch (error) {
console.error('❌ 안전 체크 조회 오류:', error);
showToast('안전 체크 정보를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.openSafetyCheckModal = openSafetyCheckModal;
// 안전 체크 모달 닫기
function closeSafetyModal() {
document.getElementById('safetyModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeSafetyModal = closeSafetyModal;
// 안전 체크리스트 저장
async function saveSafetyChecklist() {
const records = [];
document.querySelectorAll('.safety-check').forEach(cb => {
records.push({
check_id: parseInt(cb.dataset.checkId),
is_checked: cb.checked
});
});
try {
const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/safety`,
'POST',
{ records }
);
if (response && response.success) {
showToast('안전 체크가 완료되었습니다.', 'success');
closeSafetyModal();
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('❌ 안전 체크 저장 오류:', error);
showToast('안전 체크 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveSafetyChecklist = saveSafetyChecklist;
// TBM 완료 모달 열기
function openCompleteTbmModal(sessionId) {
currentSessionId = sessionId;
const now = new Date();
const timeString = now.toTimeString().slice(0, 5);
document.getElementById('endTime').value = timeString;
document.getElementById('completeModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
window.openCompleteTbmModal = openCompleteTbmModal;
// 완료 모달 닫기
function closeCompleteModal() {
document.getElementById('completeModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeCompleteModal = closeCompleteModal;
// TBM 세션 완료
async function completeTbmSession() {
const endTime = document.getElementById('endTime').value;
try {
const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/complete`,
'POST',
{ end_time: endTime }
);
if (response && response.success) {
showToast('TBM이 완료되었습니다.', 'success');
closeCompleteModal();
// 목록 새로고침
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
const date = document.getElementById('tbmDate').value;
await loadTbmSessionsByDate(date);
}
} else {
throw new Error(response.message || '완료 처리에 실패했습니다.');
}
} catch (error) {
console.error('❌ TBM 완료 처리 오류:', error);
showToast('TBM 완료 처리 중 오류가 발생했습니다.', 'error');
}
}
window.completeTbmSession = completeTbmSession;
// TBM 세션 상세 보기
async function viewTbmSession(sessionId) {
try {
// 세션 정보, 팀 구성, 안전 체크 동시 조회
const [sessionRes, teamRes, safetyRes] = await Promise.all([
window.apiCall(`/tbm/sessions/${sessionId}`),
window.apiCall(`/tbm/sessions/${sessionId}/team`),
window.apiCall(`/tbm/sessions/${sessionId}/safety`)
]);
const session = sessionRes?.data;
const team = teamRes?.data || [];
const safety = safetyRes?.data || [];
if (!session) {
showToast('세션 정보를 불러올 수 없습니다.', 'error');
return;
}
// 기본 정보 표시
const basicInfo = document.getElementById('detailBasicInfo');
basicInfo.innerHTML = `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀장</div>
<div style="font-weight: 600; color: #111827;">${session.leader_name}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">날짜</div>
<div style="font-weight: 600; color: #111827;">${session.session_date}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">프로젝트</div>
<div style="font-weight: 600; color: #111827;">${session.project_name || '-'}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 장소</div>
<div style="font-weight: 600; color: #111827;">${session.work_location || '-'}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; grid-column: span 2;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 내용</div>
<div style="color: #111827;">${session.work_description || '-'}</div>
</div>
${session.safety_notes ? `
<div style="padding: 0.75rem; background: #fef3c7; border-radius: 0.375rem; grid-column: span 2;">
<div style="font-size: 0.75rem; color: #92400e; margin-bottom: 0.25rem;">⚠️ 안전 특이사항</div>
<div style="color: #78350f;">${session.safety_notes}</div>
</div>
` : ''}
`;
// 팀 구성 표시
const teamMembers = document.getElementById('detailTeamMembers');
if (team.length === 0) {
teamMembers.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">등록된 팀원이 없습니다.</p>';
} else {
teamMembers.innerHTML = team.map(member => `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<div style="font-weight: 600; color: #111827; margin-bottom: 0.25rem;">${member.worker_name}</div>
<div style="font-size: 0.75rem; color: #6b7280;">${member.job_type || ''}</div>
${member.is_present ? '' : '<div style="font-size: 0.75rem; color: #ef4444; margin-top: 0.25rem;">결석</div>'}
</div>
`).join('');
}
// 안전 체크 표시
const safetyChecks = document.getElementById('detailSafetyChecks');
if (safety.length === 0) {
safetyChecks.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">안전 체크 기록이 없습니다.</p>';
} else {
// 카테고리별 그룹화
const grouped = {};
safety.forEach(check => {
if (!grouped[check.check_category]) {
grouped[check.check_category] = [];
}
grouped[check.check_category].push(check);
});
const categoryNames = {
'PPE': '개인 보호 장비',
'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경',
'EMERGENCY': '비상 대응'
};
safetyChecks.innerHTML = Object.keys(grouped).map(category => `
<div style="margin-bottom: 1rem;">
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; margin-bottom: 0.5rem; padding: 0.5rem; background: #f3f4f6; border-radius: 0.25rem;">
${categoryNames[category] || category}
</div>
<div style="display: grid; gap: 0.5rem;">
${grouped[category].map(check => `
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-radius: 0.25rem; ${check.is_checked ? 'background: #f0fdf4;' : 'background: #fef2f2;'}">
<span style="font-size: 1.25rem;">${check.is_checked ? '✅' : '❌'}</span>
<span style="flex: 1; font-size: 0.875rem; color: #374151;">${check.check_item}</span>
</div>
`).join('')}
</div>
</div>
`).join('');
}
document.getElementById('detailModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
} catch (error) {
console.error('❌ TBM 상세 조회 오류:', error);
showToast('상세 정보를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.viewTbmSession = viewTbmSession;
// 상세보기 모달 닫기
function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeDetailModal = closeDetailModal;
// 작업 인계 모달 열기
async function openHandoverModal(sessionId) {
currentSessionId = sessionId;
// 세션 정보와 팀 구성 조회
try {
const [sessionRes, teamRes] = await Promise.all([
window.apiCall(`/tbm/sessions/${sessionId}`),
window.apiCall(`/tbm/sessions/${sessionId}/team`)
]);
const session = sessionRes?.data;
const team = teamRes?.data || [];
if (!session) {
showToast('세션 정보를 불러올 수 없습니다.', 'error');
return;
}
// 현재 세션의 팀장을 제외한 리더 목록
const toLeaderSelect = document.getElementById('toLeaderId');
const otherLeaders = allWorkers.filter(w =>
(w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin') &&
w.worker_id !== session.leader_id
);
toLeaderSelect.innerHTML = '<option value="">인수자 선택...</option>' +
otherLeaders.map(w => `
<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</option>
`).join('');
// 인계할 팀원 목록
const handoverTeamList = document.getElementById('handoverTeamList');
if (team.length === 0) {
handoverTeamList.innerHTML = '<p style="padding: 1rem; color: #6b7280; text-align: center;">팀 구성이 없습니다.</p>';
} else {
handoverTeamList.innerHTML = team.map(member => `
<label style="display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; cursor: pointer; border-radius: 0.25rem; transition: background 0.2s;"
onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background='white'">
<input type="checkbox"
class="handover-worker-checkbox"
value="${member.worker_id}"
checked
style="width: 16px; height: 16px; cursor: pointer;">
<span style="font-weight: 500; font-size: 0.875rem;">${member.worker_name}</span>
<span style="font-size: 0.75rem; color: #6b7280; margin-left: auto;">${member.job_type || ''}</span>
</label>
`).join('');
}
// 기본값 설정
document.getElementById('handoverSessionId').value = sessionId;
const today = 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';
document.body.style.overflow = 'hidden';
} catch (error) {
console.error('❌ 인계 모달 열기 오류:', error);
showToast('인계 정보를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.openHandoverModal = openHandoverModal;
// 인계 모달 닫기
function closeHandoverModal() {
document.getElementById('handoverModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
window.closeHandoverModal = closeHandoverModal;
// 작업 인계 저장
async function saveHandover() {
const sessionId = currentSessionId;
const toLeaderId = parseInt(document.getElementById('toLeaderId').value);
const reason = document.getElementById('handoverReason').value;
const handoverDate = document.getElementById('handoverDate').value;
const handoverTime = document.getElementById('handoverTime').value;
const handoverNotes = document.getElementById('handoverNotes').value;
if (!toLeaderId || !reason || !handoverDate) {
showToast('필수 항목을 입력해주세요.', 'error');
return;
}
// 인계할 작업자 목록
const workerIds = [];
document.querySelectorAll('.handover-worker-checkbox:checked').forEach(cb => {
workerIds.push(parseInt(cb.value));
});
if (workerIds.length === 0) {
showToast('인계할 팀원을 최소 1명 이상 선택해주세요.', 'error');
return;
}
try {
// 세션 정보 조회 (from_leader_id 가져오기)
const sessionRes = await window.apiCall(`/tbm/sessions/${sessionId}`);
const fromLeaderId = sessionRes?.data?.leader_id;
if (!fromLeaderId) {
showToast('세션 정보를 찾을 수 없습니다.', 'error');
return;
}
const handoverData = {
session_id: sessionId,
from_leader_id: fromLeaderId,
to_leader_id: toLeaderId,
handover_date: handoverDate,
handover_time: handoverTime,
reason: reason,
handover_notes: handoverNotes,
worker_ids: workerIds
};
const response = await window.apiCall('/tbm/handovers', 'POST', handoverData);
if (response && response.success) {
showToast('작업 인계가 요청되었습니다.', 'success');
closeHandoverModal();
} else {
throw new Error(response.message || '인계 요청에 실패했습니다.');
}
} catch (error) {
console.error('❌ 작업 인계 저장 오류:', error);
showToast('작업 인계 중 오류가 발생했습니다.', 'error');
}
}
window.saveHandover = saveHandover;
// 토스트 알림
function showToast(message, type = 'info', duration = 3000) {
const container = document.getElementById('toastContainer');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const iconMap = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
toast.innerHTML = `
<div class="toast-icon">${iconMap[type] || ''}</div>
<div class="toast-message">${message}</div>
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
`;
toast.style.cssText = `
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
margin-bottom: 0.75rem;
min-width: 300px;
animation: slideIn 0.3s ease-out;
`;
container.appendChild(toast);
setTimeout(() => {
if (toast.parentElement) {
toast.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
}
}, duration);
}