Files
TK-FB-Project/web-ui/js/tbm.js
Hyungi Ahn 74d3a78aa3 feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현
- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성
  - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동
  - common/ → attendance/: 근태/휴가 관련 페이지 이동
  - admin/ 정리: safety-* 파일들을 safety/로 이동

- 사이드바 네비게이션 메뉴 구현
  - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리
  - 접기/펼치기 기능 및 상태 저장
  - 관리자 전용 메뉴 자동 표시/숨김

- 날씨 API 연동 (기상청 단기예보)
  - TBM 및 navbar에 현재 날씨 표시
  - weatherService.js 추가

- 안전 체크리스트 확장
  - 기본/날씨별/작업별 체크 유형 추가
  - checklist-manage.html 페이지 추가

- 이슈 신고 시스템 구현
  - workIssueController, workIssueModel, workIssueRoutes 추가

- DB 마이그레이션 파일 추가 (실행 대기)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:27:22 +09:00

2551 lines
91 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(); // 일괄 설정에서 선택된 작업자 인덱스
// TBM 관리 탭용 변수
let loadedDaysCount = 7; // 처음에 로드할 일수
let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션
let allLoadedSessions = []; // 전체 로드된 세션
// ==================== 유틸리티 함수 ====================
/**
* 서울 시간대(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();
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() {
// 날짜 선택기 제거됨 - 날짜별 그룹 뷰 사용
}
// 초기 데이터 로드
async function loadInitialData() {
try {
// 현재 로그인한 사용자 정보 가져오기
const userInfo = JSON.parse(localStorage.getItem('user') || '{}');
currentUser = userInfo;
console.log('👤 로그인 사용자:', currentUser, 'worker_id:', currentUser?.worker_id);
// 작업자 목록 로드
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') {
loadRecentTbmGroupedByDate();
}
}
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() {
await loadRecentTbmGroupedByDate();
}
window.loadTodayTbm = loadTodayTbm;
// 전체 TBM 로드 - 레거시 호환
async function loadAllTbm() {
loadedDaysCount = 30; // 30일치 로드
await loadRecentTbmGroupedByDate();
}
window.loadAllTbm = loadAllTbm;
// ==================== 날짜별 그룹 TBM 로드 (새 기능) ====================
/**
* 사용자가 Admin인지 확인
*/
function isAdminUser() {
if (!currentUser) return false;
return currentUser.role === 'Admin' || currentUser.role === 'System Admin';
}
/**
* 최근 TBM을 날짜별로 그룹화하여 로드
*/
async function loadRecentTbmGroupedByDate() {
try {
const today = new Date();
const dates = [];
// 최근 N일의 날짜 생성
for (let i = 0; i < loadedDaysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
dates.push(dateStr);
}
// 각 날짜의 TBM 로드
dateGroupedSessions = {};
allLoadedSessions = [];
const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`));
const results = await Promise.all(promises);
results.forEach((response, index) => {
const date = dates[index];
if (response && response.success && response.data && response.data.length > 0) {
let sessions = response.data;
// admin이 아니면 본인이 작성한 TBM만 필터링
if (!isAdminUser()) {
const userId = currentUser?.user_id;
const workerId = currentUser?.worker_id;
sessions = sessions.filter(s => {
return s.created_by === userId ||
s.leader_id === workerId ||
s.created_by_name === currentUser?.name;
});
}
if (sessions.length > 0) {
dateGroupedSessions[date] = sessions;
allLoadedSessions = allLoadedSessions.concat(sessions);
}
}
});
// 날짜별 그룹 표시
displayTbmGroupedByDate();
// 뷰 모드 표시
updateViewModeIndicator();
} catch (error) {
console.error('❌ TBM 날짜별 로드 오류:', error);
showToast('TBM을 불러오는 중 오류가 발생했습니다.', 'error');
dateGroupedSessions = {};
displayTbmGroupedByDate();
}
}
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="date-group">
<div class="date-group-header ${isToday ? 'today' : ''}">
<span class="date-group-date">${displayDate}</span>
<span class="date-group-day">${dayName}요일${isToday ? ' (오늘)' : ''}</span>
<span class="date-group-count">${sessions.length}건</span>
</div>
<div class="date-group-grid">
${sessions.map(session => createSessionCard(session)).join('')}
</div>
</div>
`;
}).join('');
}
/**
* 더 많은 날짜 로드
*/
async function loadMoreTbmDays() {
loadedDaysCount += 7; // 7일씩 추가
await loadRecentTbmGroupedByDate();
showToast(`최근 ${loadedDaysCount}일의 TBM을 로드했습니다.`, 'success');
}
window.loadMoreTbmDays = loadMoreTbmDays;
// 특정 날짜의 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() {
// 새 날짜별 그룹 뷰로 리다이렉트
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="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;
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) {
const jobTypeText = worker.job_type ? ` (${worker.job_type})` : '';
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name}${jobTypeText}</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 => {
const jobTypeText = w.job_type ? ` (${w.job_type})` : '';
return `<option value="${w.worker_id}">${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="${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-checks/filtered`);
if (!response || !response.success) {
throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.');
}
const { basic, weather, task, weatherInfo } = response.data;
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';
document.body.style.overflow = 'hidden';
} 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';
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);
}