- ${tbm.worker_name || '작업자'}${attendanceBadgeHtml}
+ ${tbm.worker_name || '작업자'}
${tbm.job_type || '-'}
|
- ${tbm.project_name || '-'} |
- ${tbm.work_type_name || '-'} |
- ${tbm.task_name || '-'} |
-
+ | ${tbm.project_name || '-'} |
+ ${tbm.work_type_name || '-'} |
+ ${tbm.task_name || '-'} |
+
${tbm.category_name || ''}
${tbm.workplace_name || '-'}
|
-
- |
@@ -593,6 +519,10 @@ window.calculateRegularHours = function(index) {
* TBM 작업보고서 제출
*/
window.submitTbmWorkReport = async function(index) {
+ // busy guard - 중복 제출 방지
+ const submitBtn = document.querySelector(`tr[data-index="${index}"][data-type="tbm"] .btn-submit-compact`);
+ if (submitBtn && submitBtn.classList.contains('is-loading')) return;
+
const tbm = incompleteTbms[index];
const totalHours = parseFloat(document.getElementById(`totalHours_${index}`).value);
@@ -614,6 +544,13 @@ window.submitTbmWorkReport = async function(index) {
return;
}
+ // 로딩 상태 시작
+ if (submitBtn) {
+ submitBtn.classList.add('is-loading');
+ submitBtn.disabled = true;
+ submitBtn.textContent = '제출 중';
+ }
+
// 부적합 원인 유효성 검사 (issue_report_id 또는 category_id 또는 error_type_id 필요)
console.log('🔍 부적합 검증 시작:', defects.map(d => ({
defect_hours: d.defect_hours,
@@ -722,6 +659,13 @@ window.submitTbmWorkReport = async function(index) {
} catch (error) {
console.error('TBM 작업보고서 제출 오류:', error);
showSaveResultModal('error', '제출 실패', error.message);
+ } finally {
+ // 로딩 상태 해제
+ if (submitBtn) {
+ submitBtn.classList.remove('is-loading');
+ submitBtn.disabled = false;
+ submitBtn.textContent = '제출';
+ }
}
};
@@ -729,6 +673,10 @@ window.submitTbmWorkReport = async function(index) {
* TBM 세션 일괄제출
*/
window.batchSubmitTbmSession = async function(sessionKey) {
+ // busy guard - 일괄제출 버튼
+ const batchBtn = document.querySelector(`[data-session-key="${sessionKey}"] ~ .batch-submit-container .btn-batch-submit, .tbm-session-group[data-session-key="${sessionKey}"] .btn-batch-submit`);
+ if (batchBtn && batchBtn.classList.contains('is-loading')) return;
+
// 해당 세션의 모든 항목 가져오기
const sessionRows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"]`);
@@ -804,7 +752,8 @@ window.batchSubmitTbmSession = async function(sessionKey) {
}
// 2단계: 모든 항목 제출
- const submitBtn = event.target;
+ const submitBtn = batchBtn || event.target;
+ submitBtn.classList.add('is-loading');
submitBtn.disabled = true;
submitBtn.textContent = '제출 중...';
@@ -869,6 +818,7 @@ window.batchSubmitTbmSession = async function(sessionKey) {
console.error('일괄제출 오류:', error);
showSaveResultModal('error', '일괄제출 오류', error.message);
} finally {
+ submitBtn.classList.remove('is-loading');
submitBtn.disabled = false;
submitBtn.textContent = `📤 이 세션 일괄제출 (${sessionRows.length}건)`;
}
@@ -1160,7 +1110,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath, workplaces) {
mapCtx = mapCanvas.getContext('2d');
// 이미지 URL 생성
- const baseUrl = window.API_BASE_URL || 'http://localhost:30005';
+ const baseUrl = window.API_BASE_URL || 'http://localhost:20005';
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
const fullImageUrl = layoutImagePath.startsWith('http')
? layoutImagePath
@@ -1690,12 +1640,8 @@ window.submitAllManualWorkReports = async function() {
* 날짜 포맷 함수
*/
function formatDate(dateString) {
- if (!dateString) return '';
- 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}`;
+ if (window.CommonUtils) return window.CommonUtils.formatDate(dateString);
+ return formatDateForApi(dateString);
}
/**
@@ -2015,38 +1961,36 @@ window.deleteWorkReport = async function(reportId) {
// 기존 함수들
// =================================================================
-// 한국 시간 기준 오늘 날짜 가져오기
+// 한국 시간 기준 오늘 날짜
function getKoreaToday() {
- const today = new Date();
- const year = today.getFullYear();
- const month = String(today.getMonth() + 1).padStart(2, '0');
- const day = String(today.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
+ if (window.CommonUtils) return window.CommonUtils.getTodayKST();
+ const now = new Date();
+ return now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0');
}
// 현재 로그인한 사용자 정보 가져오기
function getCurrentUser() {
- try {
- const token = localStorage.getItem('sso_token');
- if (!token) return null;
+ // SSO 사용자 정보 우선
+ if (window.getSSOUser) {
+ const ssoUser = window.getSSOUser();
+ if (ssoUser) return ssoUser;
+ }
- const payloadBase64 = token.split('.')[1];
- if (payloadBase64) {
- const payload = JSON.parse(atob(payloadBase64));
- console.log('토큰에서 추출한 사용자 정보:', payload);
- return payload;
+ try {
+ const token = window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_token') || localStorage.getItem('token'));
+ if (token) {
+ const payloadBase64 = token.split('.')[1];
+ if (payloadBase64) {
+ return JSON.parse(atob(payloadBase64));
+ }
}
} catch (error) {
console.log('토큰에서 사용자 정보 추출 실패:', error);
}
try {
- const userInfo = localStorage.getItem('sso_user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
- if (userInfo) {
- const parsed = JSON.parse(userInfo);
- console.log('localStorage에서 가져온 사용자 정보:', parsed);
- return parsed;
- }
+ const userInfo = localStorage.getItem('sso_user') || localStorage.getItem('user') || localStorage.getItem('userInfo');
+ if (userInfo) return JSON.parse(userInfo);
} catch (error) {
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
}
@@ -3183,14 +3127,15 @@ function setupEventListeners() {
// 초기화
async function init() {
try {
- const token = localStorage.getItem('sso_token');
- if (!token || token === 'undefined') {
- showMessage('로그인이 필요합니다.', 'error');
- localStorage.removeItem('sso_token');
- setTimeout(() => {
- window.location.href = '/';
- }, 2000);
- return;
+ // app-init.js(defer)가 토큰/apiCall 설정 완료할 때까지 대기
+ if (window.waitForApi) {
+ await window.waitForApi(8000);
+ } else if (!window.apiCall) {
+ // waitForApi 없으면 간단 폴링
+ await new Promise((resolve, reject) => {
+ let elapsed = 0;
+ const iv = setInterval(() => { elapsed += 50; if (window.apiCall) { clearInterval(iv); resolve(); } else if (elapsed >= 8000) { clearInterval(iv); reject(new Error('apiCall timeout')); } }, 50);
+ });
}
await loadData();
@@ -3207,8 +3152,12 @@ async function init() {
}
}
-// 페이지 로드 시 초기화
-document.addEventListener('DOMContentLoaded', init);
+// 페이지 로드 시 초기화 (module 스크립트는 DOMContentLoaded 이후 실행될 수 있음)
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+} else {
+ init();
+}
// 전역 함수로 노출
window.removeWorkEntry = removeWorkEntry;
diff --git a/system1-factory/web/js/daily-work-report/index.js b/system1-factory/web/js/daily-work-report/index.js
deleted file mode 100644
index 03184dd..0000000
--- a/system1-factory/web/js/daily-work-report/index.js
+++ /dev/null
@@ -1,318 +0,0 @@
-/**
- * Daily Work Report - Module Loader
- * 작업보고서 모듈을 초기화하고 연결하는 메인 진입점
- *
- * 로드 순서:
- * 1. state.js - 전역 상태 관리
- * 2. utils.js - 유틸리티 함수
- * 3. api.js - API 클라이언트
- * 4. index.js - 이 파일 (메인 컨트롤러)
- */
-
-class DailyWorkReportController {
- constructor() {
- this.state = window.DailyWorkReportState;
- this.api = window.DailyWorkReportAPI;
- this.utils = window.DailyWorkReportUtils;
- this.initialized = false;
-
- console.log('[Controller] DailyWorkReportController 생성');
- }
-
- /**
- * 초기화
- */
- async init() {
- if (this.initialized) {
- console.log('[Controller] 이미 초기화됨');
- return;
- }
-
- console.log('[Controller] 초기화 시작...');
-
- try {
- // 이벤트 리스너 설정
- this.setupEventListeners();
-
- // 기본 데이터 로드
- await this.api.loadAllData();
-
- // TBM 탭이 기본
- await this.switchTab('tbm');
-
- this.initialized = true;
- console.log('[Controller] 초기화 완료');
-
- } catch (error) {
- console.error('[Controller] 초기화 실패:', error);
- window.showMessage?.('초기화 중 오류가 발생했습니다: ' + error.message, 'error');
- }
- }
-
- /**
- * 이벤트 리스너 설정
- */
- setupEventListeners() {
- // 탭 버튼
- const tbmBtn = document.getElementById('tbmReportTab');
- const completedBtn = document.getElementById('completedReportTab');
-
- if (tbmBtn) {
- tbmBtn.addEventListener('click', () => this.switchTab('tbm'));
- }
- if (completedBtn) {
- completedBtn.addEventListener('click', () => this.switchTab('completed'));
- }
-
- // 완료 보고서 날짜 변경
- const completedDateInput = document.getElementById('completedReportDate');
- if (completedDateInput) {
- completedDateInput.addEventListener('change', () => this.loadCompletedReports());
- }
-
- console.log('[Controller] 이벤트 리스너 설정 완료');
- }
-
- /**
- * 탭 전환
- */
- async switchTab(tab) {
- this.state.setCurrentTab(tab);
-
- const tbmBtn = document.getElementById('tbmReportTab');
- const completedBtn = document.getElementById('completedReportTab');
- const tbmSection = document.getElementById('tbmReportSection');
- const completedSection = document.getElementById('completedReportSection');
-
- // 모든 탭 버튼 비활성화
- tbmBtn?.classList.remove('active');
- completedBtn?.classList.remove('active');
-
- // 모든 섹션 숨기기
- if (tbmSection) tbmSection.style.display = 'none';
- if (completedSection) completedSection.style.display = 'none';
-
- // 선택된 탭 활성화
- if (tab === 'tbm') {
- tbmBtn?.classList.add('active');
- if (tbmSection) tbmSection.style.display = 'block';
- await this.loadTbmData();
- } else if (tab === 'completed') {
- completedBtn?.classList.add('active');
- if (completedSection) completedSection.style.display = 'block';
-
- // 오늘 날짜로 초기화
- const dateInput = document.getElementById('completedReportDate');
- if (dateInput) {
- dateInput.value = this.utils.getKoreaToday();
- }
- await this.loadCompletedReports();
- }
- }
-
- /**
- * TBM 데이터 로드
- */
- async loadTbmData() {
- try {
- await this.api.loadIncompleteTbms();
- await this.api.loadDailyIssuesForTbms();
-
- // 렌더링은 기존 함수 사용 (점진적 마이그레이션)
- if (typeof window.renderTbmWorkList === 'function') {
- window.renderTbmWorkList();
- }
- } catch (error) {
- console.error('[Controller] TBM 데이터 로드 오류:', error);
- window.showMessage?.('TBM 데이터를 불러오는 중 오류가 발생했습니다.', 'error');
- }
- }
-
- /**
- * 완료 보고서 로드
- */
- async loadCompletedReports() {
- try {
- const dateInput = document.getElementById('completedReportDate');
- const date = dateInput?.value || this.utils.getKoreaToday();
-
- const reports = await this.api.loadCompletedReports(date);
-
- // 렌더링은 기존 함수 사용
- if (typeof window.renderCompletedReports === 'function') {
- window.renderCompletedReports(reports);
- }
- } catch (error) {
- console.error('[Controller] 완료 보고서 로드 오류:', error);
- window.showMessage?.('완료 보고서를 불러오는 중 오류가 발생했습니다.', 'error');
- }
- }
-
- /**
- * TBM 작업보고서 제출
- */
- async submitTbmWorkReport(index) {
- try {
- const tbm = this.state.incompleteTbms[index];
- if (!tbm) {
- throw new Error('TBM 데이터를 찾을 수 없습니다.');
- }
-
- // 유효성 검사
- const totalHoursInput = document.getElementById(`totalHours_${index}`);
- const totalHours = parseFloat(totalHoursInput?.value);
-
- if (!totalHours || totalHours <= 0) {
- window.showMessage?.('작업시간을 입력해주세요.', 'warning');
- return;
- }
-
- // 부적합 시간 계산
- const defects = this.state.tempDefects[index] || [];
- const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
- const regularHours = totalHours - errorHours;
-
- if (regularHours < 0) {
- window.showMessage?.('부적합 시간이 총 작업시간을 초과할 수 없습니다.', 'warning');
- return;
- }
-
- // API 데이터 구성
- const user = this.state.getCurrentUser();
- const reportData = {
- tbm_session_id: tbm.session_id,
- tbm_assignment_id: tbm.assignment_id,
- worker_id: tbm.worker_id,
- project_id: tbm.project_id,
- work_type_id: tbm.work_type_id,
- report_date: this.utils.formatDateForApi(tbm.session_date),
- total_hours: totalHours,
- regular_hours: regularHours,
- error_hours: errorHours,
- work_status_id: errorHours > 0 ? 2 : 1,
- created_by: user?.user_id || user?.id,
- defects: defects.map(d => ({
- category_id: d.category_id,
- item_id: d.item_id,
- issue_report_id: d.issue_report_id,
- defect_hours: d.defect_hours,
- note: d.note
- }))
- };
-
- const result = await this.api.submitTbmWorkReport(reportData);
-
- window.showSaveResultModal?.(
- 'success',
- '제출 완료',
- `${tbm.worker_name}의 작업보고서가 제출되었습니다.`
- );
-
- // 목록 새로고침
- await this.loadTbmData();
-
- } catch (error) {
- console.error('[Controller] 제출 오류:', error);
- window.showSaveResultModal?.(
- 'error',
- '제출 실패',
- error.message || '작업보고서 제출 중 오류가 발생했습니다.'
- );
- }
- }
-
- /**
- * 세션 일괄 제출
- */
- async batchSubmitSession(sessionKey) {
- const rows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"][data-type="tbm"]`);
- const indices = [];
-
- rows.forEach(row => {
- const index = parseInt(row.dataset.index);
- const totalHoursInput = document.getElementById(`totalHours_${index}`);
- if (totalHoursInput?.value && parseFloat(totalHoursInput.value) > 0) {
- indices.push(index);
- }
- });
-
- if (indices.length === 0) {
- window.showMessage?.('제출할 항목이 없습니다. 작업시간을 입력해주세요.', 'warning');
- return;
- }
-
- const confirmed = confirm(`${indices.length}건의 작업보고서를 일괄 제출하시겠습니까?`);
- if (!confirmed) return;
-
- let successCount = 0;
- let failCount = 0;
-
- for (const index of indices) {
- try {
- await this.submitTbmWorkReport(index);
- successCount++;
- } catch (error) {
- failCount++;
- console.error(`[Controller] 일괄 제출 오류 (index: ${index}):`, error);
- }
- }
-
- if (failCount === 0) {
- window.showSaveResultModal?.('success', '일괄 제출 완료', `${successCount}건이 성공적으로 제출되었습니다.`);
- } else {
- window.showSaveResultModal?.('warning', '일괄 제출 부분 완료', `성공: ${successCount}건, 실패: ${failCount}건`);
- }
- }
-
- /**
- * 상태 디버그
- */
- debug() {
- console.log('[Controller] 상태 디버그:');
- this.state.debug();
- }
-}
-
-// 전역 인스턴스 생성
-window.DailyWorkReportController = new DailyWorkReportController();
-
-// 하위 호환성: 기존 전역 함수들
-window.switchTab = (tab) => window.DailyWorkReportController.switchTab(tab);
-window.submitTbmWorkReport = (index) => window.DailyWorkReportController.submitTbmWorkReport(index);
-window.batchSubmitTbmSession = (sessionKey) => window.DailyWorkReportController.batchSubmitSession(sessionKey);
-
-// 사용자 정보 함수
-window.getUser = () => window.DailyWorkReportState.getUser();
-window.getCurrentUser = () => window.DailyWorkReportState.getCurrentUser();
-
-// 날짜 그룹 토글 (UI 함수)
-window.toggleDateGroup = function(dateStr) {
- const group = document.querySelector(`.date-group[data-date="${dateStr}"]`);
- if (!group) return;
-
- const isExpanded = group.classList.contains('expanded');
- const content = group.querySelector('.date-group-content');
- const icon = group.querySelector('.date-toggle-icon');
-
- if (isExpanded) {
- group.classList.remove('expanded');
- group.classList.add('collapsed');
- if (content) content.style.display = 'none';
- if (icon) icon.textContent = '▶';
- } else {
- group.classList.remove('collapsed');
- group.classList.add('expanded');
- if (content) content.style.display = 'block';
- if (icon) icon.textContent = '▼';
- }
-};
-
-// DOMContentLoaded 이벤트에서 초기화
-document.addEventListener('DOMContentLoaded', () => {
- // 약간의 지연 후 초기화 (다른 스크립트 로드 대기)
- setTimeout(() => {
- window.DailyWorkReportController.init();
- }, 100);
-});
-
-console.log('[Module] daily-work-report/index.js 로드 완료');
diff --git a/system1-factory/web/js/daily-work-report/state.js b/system1-factory/web/js/daily-work-report/state.js
index ff44fbf..2df535c 100644
--- a/system1-factory/web/js/daily-work-report/state.js
+++ b/system1-factory/web/js/daily-work-report/state.js
@@ -1,10 +1,12 @@
/**
* Daily Work Report - State Manager
- * 작업보고서 페이지의 전역 상태 관리
+ * 작업보고서 페이지의 전역 상태 관리 (BaseState 상속)
*/
-class DailyWorkReportState {
+class DailyWorkReportState extends BaseState {
constructor() {
+ super();
+
// 마스터 데이터
this.workTypes = [];
this.workStatusTypes = [];
@@ -45,53 +47,9 @@ class DailyWorkReportState {
// 캐시
this.dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] }
- // 리스너
- this.listeners = new Map();
-
console.log('[State] DailyWorkReportState 초기화 완료');
}
- /**
- * 상태 업데이트
- */
- update(key, value) {
- const prevValue = this[key];
- this[key] = value;
- this.notifyListeners(key, value, prevValue);
- }
-
- /**
- * 리스너 등록
- */
- subscribe(key, callback) {
- if (!this.listeners.has(key)) {
- this.listeners.set(key, []);
- }
- this.listeners.get(key).push(callback);
- }
-
- /**
- * 리스너에게 알림
- */
- notifyListeners(key, newValue, prevValue) {
- const keyListeners = this.listeners.get(key) || [];
- keyListeners.forEach(callback => {
- try {
- callback(newValue, prevValue);
- } catch (error) {
- console.error(`[State] 리스너 오류 (${key}):`, error);
- }
- });
- }
-
- /**
- * 현재 사용자 정보 가져오기
- */
- getUser() {
- const user = localStorage.getItem('sso_user');
- return user ? JSON.parse(user) : null;
- }
-
/**
* 토큰에서 사용자 정보 추출
*/
diff --git a/system1-factory/web/js/daily-work-report/utils.js b/system1-factory/web/js/daily-work-report/utils.js
index 2d96e2a..370f482 100644
--- a/system1-factory/web/js/daily-work-report/utils.js
+++ b/system1-factory/web/js/daily-work-report/utils.js
@@ -1,67 +1,29 @@
/**
* Daily Work Report - Utilities
- * 작업보고서 관련 유틸리티 함수들
+ * 작업보고서 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임)
*/
class DailyWorkReportUtils {
constructor() {
+ this._common = window.CommonUtils;
console.log('[Utils] DailyWorkReportUtils 초기화');
}
- /**
- * 한국 시간 기준 오늘 날짜 (YYYY-MM-DD)
- */
- getKoreaToday() {
- const today = new Date();
- const year = today.getFullYear();
- const month = String(today.getMonth() + 1).padStart(2, '0');
- const day = String(today.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
- }
+ // --- CommonUtils 위임 ---
+ getKoreaToday() { return this._common.getTodayKST(); }
+ formatDateForApi(date) { return this._common.formatDate(date); }
+ formatDate(date) { return this._common.formatDate(date) || '-'; }
+ getDayOfWeek(date) { return this._common.getDayOfWeek(date); }
+ isToday(date) { return this._common.isToday(date); }
+ generateUUID() { return this._common.generateUUID(); }
+ escapeHtml(text) { return this._common.escapeHtml(text); }
+ debounce(func, wait) { return this._common.debounce(func, wait); }
+ throttle(func, limit) { return this._common.throttle(func, limit); }
+ deepClone(obj) { return this._common.deepClone(obj); }
+ isEmpty(value) { return this._common.isEmpty(value); }
+ groupBy(array, key) { return this._common.groupBy(array, key); }
- /**
- * 날짜를 API 형식(YYYY-MM-DD)으로 변환
- */
- formatDateForApi(date) {
- if (!date) return null;
-
- let dateObj;
- if (date instanceof Date) {
- dateObj = date;
- } else if (typeof date === 'string') {
- dateObj = new Date(date);
- } else {
- return null;
- }
-
- const year = dateObj.getFullYear();
- const month = String(dateObj.getMonth() + 1).padStart(2, '0');
- const day = String(dateObj.getDate()).padStart(2, '0');
-
- return `${year}-${month}-${day}`;
- }
-
- /**
- * 날짜 포맷팅 (표시용)
- */
- formatDate(date) {
- if (!date) return '-';
-
- let dateObj;
- if (date instanceof Date) {
- dateObj = date;
- } else if (typeof date === 'string') {
- dateObj = new Date(date);
- } else {
- return '-';
- }
-
- const year = dateObj.getFullYear();
- const month = String(dateObj.getMonth() + 1).padStart(2, '0');
- const day = String(dateObj.getDate()).padStart(2, '0');
-
- return `${year}-${month}-${day}`;
- }
+ // --- 작업보고 전용 ---
/**
* 시간 포맷팅 (HH:mm)
@@ -104,24 +66,6 @@ class DailyWorkReportUtils {
return Number(num).toFixed(decimals);
}
- /**
- * 요일 반환
- */
- getDayOfWeek(date) {
- const days = ['일', '월', '화', '수', '목', '금', '토'];
- const dateObj = date instanceof Date ? date : new Date(date);
- return days[dateObj.getDay()];
- }
-
- /**
- * 오늘인지 확인
- */
- isToday(date) {
- const today = this.getKoreaToday();
- const targetDate = this.formatDateForApi(date);
- return today === targetDate;
- }
-
/**
* 두 날짜 사이 일수 계산
*/
@@ -132,63 +76,6 @@ class DailyWorkReportUtils {
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
- /**
- * 디바운스 함수
- */
- debounce(func, wait) {
- let timeout;
- return function executedFunction(...args) {
- const later = () => {
- clearTimeout(timeout);
- func(...args);
- };
- clearTimeout(timeout);
- timeout = setTimeout(later, wait);
- };
- }
-
- /**
- * 쓰로틀 함수
- */
- throttle(func, limit) {
- let inThrottle;
- return function(...args) {
- if (!inThrottle) {
- func.apply(this, args);
- inThrottle = true;
- setTimeout(() => inThrottle = false, limit);
- }
- };
- }
-
- /**
- * HTML 이스케이프
- */
- escapeHtml(text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-
- /**
- * 객체 깊은 복사
- */
- deepClone(obj) {
- return JSON.parse(JSON.stringify(obj));
- }
-
- /**
- * 빈 값 확인
- */
- isEmpty(value) {
- if (value === null || value === undefined) return true;
- if (typeof value === 'string') return value.trim() === '';
- if (Array.isArray(value)) return value.length === 0;
- if (typeof value === 'object') return Object.keys(value).length === 0;
- return false;
- }
-
/**
* 숫자 유효성 검사
*/
@@ -249,20 +136,6 @@ class DailyWorkReportUtils {
}
}
- /**
- * 배열 그룹화
- */
- groupBy(array, key) {
- return array.reduce((result, item) => {
- const groupKey = typeof key === 'function' ? key(item) : item[key];
- if (!result[groupKey]) {
- result[groupKey] = [];
- }
- result[groupKey].push(item);
- return result;
- }, {});
- }
-
/**
* 배열 정렬 (다중 키)
*/
@@ -280,17 +153,6 @@ class DailyWorkReportUtils {
return 0;
});
}
-
- /**
- * UUID 생성
- */
- generateUUID() {
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
- const r = Math.random() * 16 | 0;
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
- }
}
// 전역 인스턴스 생성
diff --git a/system1-factory/web/js/tbm-mobile.js b/system1-factory/web/js/tbm-mobile.js
new file mode 100644
index 0000000..0be6f5e
--- /dev/null
+++ b/system1-factory/web/js/tbm-mobile.js
@@ -0,0 +1,1329 @@
+/**
+ * TBM Mobile - Main UI Logic
+ * tbm-mobile.html에서 추출된 인라인 JS (로직 변경 없음)
+ */
+(function() {
+ 'use strict';
+
+ var currentTab = 'today';
+ var allSessions = [];
+ var todaySessions = [];
+ var currentUser = null;
+ var loadedDays = 7;
+ var esc = window.escapeHtml || function(s) { return s || ''; };
+ var todayAssignments = []; // 당일 배정 현황
+
+ // 세부 편집 상태
+ var deSessionId = null;
+ var deSession = null;
+ var deMembers = [];
+ var deTasks = [];
+ var deWpCats = [];
+ var deWpMap = {}; // category_id -> [workplaces]
+ var deSelected = {}; // index -> boolean (그룹 선택용)
+
+ // 피커 상태
+ var pickerMode = ''; // 'task' | 'workplace'
+ var pickerWpStep = 'category'; // 'category' | 'place'
+ var pickerSelectedCatId = null;
+
+ // busy guard - 비동기 함수 중복 호출 방지
+ var _busy = {};
+ function isBusy(key) { return !!_busy[key]; }
+ function setBusy(key) { _busy[key] = true; }
+ function clearBusy(key) { delete _busy[key]; }
+
+ function showLoading(msg) {
+ var el = document.getElementById('loadingOverlay');
+ if (el) {
+ document.getElementById('loadingText').textContent = msg || '불러오는 중...';
+ el.classList.add('active');
+ }
+ }
+ function hideLoading() {
+ var el = document.getElementById('loadingOverlay');
+ if (el) el.classList.remove('active');
+ }
+
+ // 초기화
+ document.addEventListener('DOMContentLoaded', async function() {
+ var now = new Date();
+ var days = ['일','월','화','수','목','금','토'];
+ var dateEl = document.getElementById('headerDate');
+ if (dateEl) {
+ dateEl.textContent = now.getFullYear() + '.' +
+ String(now.getMonth()+1).padStart(2,'0') + '.' +
+ String(now.getDate()).padStart(2,'0') + ' (' + days[now.getDay()] + ')';
+ }
+
+ try {
+ await window.waitForApi(8000);
+ } catch(e) {
+ document.getElementById('tbmContent').innerHTML =
+ '⚠ 서버 연결에 실패했습니다 페이지를 새로고침해 주세요 ';
+ return;
+ }
+
+ currentUser = JSON.parse(localStorage.getItem('sso_user') || '{}');
+ await loadData();
+ });
+
+ function getTodayStr() {
+ var now = new Date();
+ return now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0');
+ }
+
+ async function loadData() {
+ try {
+ var today = new Date();
+ var todayStr = getTodayStr();
+ var dates = [];
+ for (var i = 0; i < loadedDays; i++) {
+ var d = new Date(today);
+ d.setDate(d.getDate() - i);
+ dates.push(d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'));
+ }
+
+ var API = window.TbmAPI;
+ var promises = dates.map(function(date) {
+ return API.fetchSessionsByDate(date);
+ });
+ var results = await Promise.all(promises);
+
+ allSessions = [];
+ results.forEach(function(sessions) {
+ if (sessions && sessions.length > 0) {
+ allSessions = allSessions.concat(sessions);
+ }
+ });
+
+ // 당일 세션 = 오늘 날짜만
+ todaySessions = allSessions.filter(function(s) {
+ var sDate = s.session_date ? s.session_date.split('T')[0] : '';
+ return sDate === todayStr;
+ });
+
+ document.getElementById('todayCount').textContent = todaySessions.length;
+ document.getElementById('allCount').textContent = allSessions.length;
+
+ renderList();
+ } catch (error) {
+ console.error('TBM 로드 오류:', error);
+ document.getElementById('tbmContent').innerHTML =
+ '';
+ }
+ }
+
+ window.switchTab = function(tab) {
+ currentTab = tab;
+ document.querySelectorAll('.m-tab').forEach(function(el) {
+ el.classList.toggle('active', el.dataset.tab === tab);
+ });
+ renderList();
+ };
+
+ function isMySession(s) {
+ var userId = currentUser.user_id;
+ var workerId = currentUser.worker_id;
+ var userName = currentUser.name;
+ return (userId && String(s.created_by) === String(userId)) ||
+ (workerId && String(s.leader_id) === String(workerId)) ||
+ (userName && s.created_by_name === userName);
+ }
+
+ function renderList() {
+ var sessions = currentTab === 'today' ? todaySessions : allSessions;
+ var content = document.getElementById('tbmContent');
+
+ if (sessions.length === 0) {
+ var emptyMsg = currentTab === 'today' ?
+ '오늘 등록된 TBM이 없습니다' : '등록된 TBM이 없습니다';
+ content.innerHTML =
+ '' +
+ ' 📝 ' +
+ ' ' + emptyMsg + ' ' +
+ (currentTab === 'all' ? ' 최근 ' + loadedDays + '일 기준 ' : '') +
+ ' ';
+ return;
+ }
+
+ var grouped = {};
+ sessions.forEach(function(s) {
+ var date = s.session_date ? s.session_date.split('T')[0] : '';
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { /* ok */ }
+ else if (s.session_date) { date = new Date(s.session_date).toISOString().split('T')[0]; }
+ if (!grouped[date]) grouped[date] = [];
+ grouped[date].push(s);
+ });
+
+ var sortedDates = Object.keys(grouped).sort().reverse();
+ var todayStr = getTodayStr();
+ var yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
+
+ var html = '';
+ sortedDates.forEach(function(date) {
+ var label = date;
+ if (date === todayStr) label = '오늘';
+ else if (date === yesterday) label = '어제';
+ else {
+ var parts = date.split('-');
+ var dayNames = ['일','월','화','수','목','금','토'];
+ var dObj = new Date(date + 'T00:00:00');
+ label = parseInt(parts[1]) + '/' + parseInt(parts[2]) + ' (' + dayNames[dObj.getDay()] + ')';
+ }
+
+ html += '' + label + ' ';
+
+ grouped[date].forEach(function(s) {
+ var sid = s.session_id;
+ var status = s.status || 'draft';
+ var leaderName = s.leader_name || s.created_by_name || '미지정';
+ var memberCount = (parseInt(s.team_member_count) || 0);
+ var memberNames = s.team_member_names || '';
+ var subText = memberNames || '팀원 없음';
+ var isMine = isMySession(s);
+ var transferCount = parseInt(s.transfer_count) || 0;
+
+ var createdTime = '';
+ if (s.created_at) {
+ try {
+ var t = new Date(s.created_at);
+ createdTime = String(t.getHours()).padStart(2,'0') + ':' + String(t.getMinutes()).padStart(2,'0');
+ } catch(e) {}
+ }
+
+ var statusLabel = status === 'completed' ? '완료' : (status === 'cancelled' ? '취소' : '진행');
+
+ var badge = '';
+ if (status === 'draft') {
+ if (!s.task_id) {
+ badge = '세부 미입력';
+ } else {
+ badge = '입력 완료';
+ }
+ }
+
+ // 이동 뱃지
+ var transferBadge = '';
+ if (transferCount > 0) {
+ transferBadge = '' + transferCount + '건 이동';
+ }
+
+ // 당일 탭에서 다른 반장의 draft TBM 클릭 → 빼오기 시트
+ var clickAction;
+ if (isMine && status === 'draft') {
+ clickAction = 'openDetailEditSheet(' + sid + ')';
+ } else if (!isMine && status === 'draft' && currentTab === 'today') {
+ clickAction = 'openPullSheet(' + sid + ')';
+ } else if (status !== 'draft') {
+ clickAction = 'toggleDetail(' + sid + ')';
+ } else {
+ clickAction = 'toggleDetail(' + sid + ')';
+ }
+
+ var myTbmClass = isMine ? ' my-tbm' : '';
+ var leaderDisplay = esc(leaderName);
+ if (!isMine && currentTab === 'today') {
+ leaderDisplay += '타 반장';
+ }
+
+ html += '' +
+ ' ' +
+ ' ' +
+ ' ' + leaderDisplay + badge + transferBadge + ' ' +
+ ' ' + esc(subText) + ' ' +
+ ' ' +
+ ' ' +
+ ' ' + memberCount + '명 ' +
+ (createdTime ? ' ' + createdTime + ' ' : '') +
+ ' ' +
+ ' ';
+
+ if (status !== 'draft') {
+ var taskName = s.task_name || '';
+ var workplaceName = s.work_location || '';
+ html += '' +
+ ' 상태' + statusLabel + ' ' +
+ ' 입력자' + esc(leaderName) + ' ' +
+ (taskName ? ' 작업' + esc(taskName) + ' ' : '') +
+ (workplaceName ? ' 장소' + esc(workplaceName) + ' ' : '') +
+ ' 인원' + esc(memberNames || '없음') + ' (' + memberCount + '명) ' +
+ ' ' +
+ '' +
+ ' ' +
+ ' ';
+ }
+ });
+ });
+
+ if (currentTab === 'all') {
+ html += '';
+ }
+ content.innerHTML = html;
+ }
+
+ window.toggleDetail = function(sid) {
+ var row = document.querySelector('.m-tbm-row[data-sid="' + sid + '"]');
+ if (!row) return;
+ document.querySelectorAll('.m-tbm-row.expanded').forEach(function(el) {
+ if (el !== row) el.classList.remove('expanded');
+ });
+ row.classList.toggle('expanded');
+ };
+
+ window.loadMore = function() {
+ loadedDays += 7;
+ loadData();
+ };
+
+ // ─── 세부 편집 바텀시트 ───
+
+ window.openDetailEditSheet = async function(sid) {
+ if (isBusy('detailEdit')) return;
+ setBusy('detailEdit');
+ showLoading('불러오는 중...');
+ deSessionId = sid;
+ deSelected = {};
+ try {
+ var API = window.TbmAPI;
+ var results = await Promise.all([
+ API.getSession(sid).catch(function() { return null; }),
+ API.getTeamMembers(sid).catch(function() { return []; }),
+ API.loadTasks().catch(function() { return []; }),
+ API.loadWorkplaceCategories().catch(function() { return []; }),
+ API.loadActiveWorkplacesList().catch(function() { return []; })
+ ]);
+
+ deSession = results[0];
+ deMembers = results[1];
+ deTasks = results[2] || window.TbmState.allTasks || [];
+ deWpCats = results[3] || window.TbmState.allWorkplaceCategories || [];
+ var allWorkplaces = results[4];
+
+ if (!deSession) { window.showToast('TBM 정보를 불러올 수 없습니다.', 'error'); return; }
+ if (deMembers.length === 0) { window.showToast('팀원이 없습니다.', 'error'); return; }
+
+ // work_type 필터
+ var workTypeId = deSession.work_type_id || (deMembers[0] && deMembers[0].work_type_id);
+ if (workTypeId) {
+ deTasks = deTasks.filter(function(t) { return t.work_type_id == workTypeId; });
+ }
+
+ // 작업장소 맵 (category_id 기준)
+ deWpMap = {};
+ allWorkplaces.forEach(function(wp) {
+ var catId = wp.category_id || 0;
+ if (!deWpMap[catId]) deWpMap[catId] = [];
+ deWpMap[catId].push(wp);
+ });
+
+ renderDetailEditSheet();
+ document.getElementById('deSelectAll').checked = false;
+ updateGroupBar();
+ document.getElementById('detailEditOverlay').style.display = 'block';
+ document.getElementById('detailEditSheet').style.display = 'block';
+ } catch(e) {
+ console.error('세부 편집 로드 오류:', e);
+ window.showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
+ } finally {
+ hideLoading();
+ clearBusy('detailEdit');
+ }
+ };
+
+ function renderDetailEditSheet() {
+ var html = '';
+ deMembers.forEach(function(m, i) {
+ var hasBoth = m.task_id && m.workplace_id;
+ var cardClass = hasBoth ? 'filled' : 'unfilled';
+ var statusHtml = hasBoth
+ ? '입력완료'
+ : '미입력';
+
+ // work_hours 표시
+ var workHoursTag = '';
+ if (m.work_hours !== null && m.work_hours !== undefined) {
+ workHoursTag = '' + parseFloat(m.work_hours) + 'h';
+ }
+ // 분할 항목이면 프로젝트명 표시
+ var projectTag = '';
+ if (m.split_seq > 0 && m.project_name) {
+ projectTag = '' + esc(m.project_name) + '';
+ } else if (m.project_name && m.project_id !== deSession.project_id) {
+ projectTag = '' + esc(m.project_name) + '';
+ }
+
+ var taskOptions = '';
+ deTasks.forEach(function(t) {
+ var sel = (m.task_id && m.task_id == t.task_id) ? ' selected' : '';
+ taskOptions += '';
+ });
+
+ var currentCatId = m.workplace_category_id || '';
+ var catOptions = '';
+ deWpCats.forEach(function(c) {
+ var sel = (currentCatId && currentCatId == c.category_id) ? ' selected' : '';
+ catOptions += '';
+ });
+
+ var wpOptions = '';
+ if (currentCatId && deWpMap[currentCatId]) {
+ deWpMap[currentCatId].forEach(function(wp) {
+ var sel = (m.workplace_id && m.workplace_id == wp.workplace_id) ? ' selected' : '';
+ wpOptions += '';
+ });
+ }
+
+ html += '' +
+ ' ' +
+ '' +
+ '' + esc(m.worker_name) + ' ' +
+ '' + esc(m.job_type || '') + '' +
+ workHoursTag +
+ projectTag +
+ statusHtml +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '작업' +
+ '' +
+ ' ' +
+ ' ' +
+ '장소' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ';
+ });
+ document.getElementById('deWorkerList').innerHTML = html;
+ }
+
+ window.updateCardStatus = function(idx) {
+ var card = document.getElementById('de_card_' + idx);
+ var taskVal = document.getElementById('de_task_' + idx).value;
+ var wpVal = document.getElementById('de_wp_' + idx).value;
+ var statusEl = card.querySelector('.de-worker-status');
+ if (taskVal && wpVal) {
+ card.className = 'de-worker-card filled';
+ statusEl.className = 'de-worker-status ok';
+ statusEl.textContent = '입력완료';
+ } else {
+ card.className = 'de-worker-card unfilled';
+ statusEl.className = 'de-worker-status missing';
+ statusEl.textContent = '미입력';
+ }
+ };
+
+ window.onDeWpCatChange = function(idx) {
+ var catId = document.getElementById('de_wpcat_' + idx).value;
+ var wpSel = document.getElementById('de_wp_' + idx);
+ wpSel.innerHTML = '';
+ if (catId && deWpMap[catId]) {
+ deWpMap[catId].forEach(function(wp) {
+ wpSel.innerHTML += '';
+ });
+ }
+ updateCardStatus(idx);
+ };
+
+ // ─── 그룹 선택 ───
+
+ window.onWorkerCheck = function(idx) {
+ deSelected[idx] = document.getElementById('de_check_' + idx).checked;
+ var allChecked = true;
+ for (var i = 0; i < deMembers.length; i++) {
+ if (!deSelected[i]) { allChecked = false; break; }
+ }
+ document.getElementById('deSelectAll').checked = allChecked;
+ updateGroupBar();
+ };
+
+ window.toggleSelectAll = function() {
+ var checked = document.getElementById('deSelectAll').checked;
+ for (var i = 0; i < deMembers.length; i++) {
+ deSelected[i] = checked;
+ document.getElementById('de_check_' + i).checked = checked;
+ }
+ updateGroupBar();
+ };
+
+ function getSelectedIndices() {
+ var arr = [];
+ for (var i = 0; i < deMembers.length; i++) {
+ if (deSelected[i]) arr.push(i);
+ }
+ return arr;
+ }
+
+ function updateGroupBar() {
+ var indices = getSelectedIndices();
+ var bar = document.getElementById('deGroupBar');
+ var countEl = document.getElementById('deSelectedCount');
+ var labelEl = document.getElementById('deGroupLabel');
+ if (indices.length > 0) {
+ bar.className = 'de-group-bar visible';
+ labelEl.textContent = indices.length + '명 선택';
+ countEl.textContent = indices.length + '명';
+ } else {
+ bar.className = 'de-group-bar';
+ countEl.textContent = '';
+ }
+ }
+
+ // ─── 피커 (작업/장소 선택 팝업) ───
+
+ window.openPicker = function(mode) {
+ var indices = getSelectedIndices();
+ if (indices.length === 0) {
+ window.showToast('작업자를 먼저 선택하세요.', 'error');
+ return;
+ }
+ pickerMode = mode;
+ pickerWpStep = 'category';
+ pickerSelectedCatId = null;
+
+ if (mode === 'task') {
+ renderTaskPicker();
+ } else {
+ renderWorkplaceCatPicker();
+ }
+ document.getElementById('pickerOverlay').style.display = 'block';
+ document.getElementById('pickerSheet').style.display = 'block';
+ };
+
+ window.closePicker = function() {
+ document.getElementById('pickerOverlay').style.display = 'none';
+ document.getElementById('pickerSheet').style.display = 'none';
+ };
+
+ function renderTaskPicker() {
+ document.getElementById('pickerTitle').textContent = '작업 선택';
+ var listEl = document.getElementById('pickerList');
+ var html = '';
+ deTasks.forEach(function(t) {
+ html += '' +
+ esc(t.task_name) +
+ ' ';
+ });
+ if (deTasks.length === 0) {
+ html = '등록된 작업이 없습니다 ';
+ }
+ listEl.innerHTML = html;
+ // 새 작업 추가 영역
+ var addRow = document.getElementById('pickerAddRow');
+ addRow.style.display = 'flex';
+ document.getElementById('pickerAddInput').placeholder = '새 작업명 입력...';
+ document.getElementById('pickerAddInput').value = '';
+ document.getElementById('pickerAddBtn').onclick = function() { addNewTask(); };
+ }
+
+ function renderWorkplaceCatPicker() {
+ pickerWpStep = 'category';
+ document.getElementById('pickerTitle').textContent = '장소 분류 선택';
+ var listEl = document.getElementById('pickerList');
+ var html = '';
+ deWpCats.forEach(function(c) {
+ var count = deWpMap[c.category_id] ? deWpMap[c.category_id].length : 0;
+ html += '' +
+ esc(c.category_name) +
+ '' + count + '개 장소' +
+ ' ';
+ });
+ if (deWpCats.length === 0) {
+ html = '등록된 분류가 없습니다 ';
+ }
+ listEl.innerHTML = html;
+ document.getElementById('pickerAddRow').style.display = 'none';
+ }
+
+ function renderWorkplacePicker(catId) {
+ pickerWpStep = 'place';
+ pickerSelectedCatId = catId;
+ var catName = '';
+ deWpCats.forEach(function(c) { if (c.category_id == catId) catName = c.category_name; });
+ document.getElementById('pickerTitle').textContent = esc(catName) + ' - 장소 선택';
+ var listEl = document.getElementById('pickerList');
+ var workplaces = deWpMap[catId] || [];
+ var html = '← 분류 다시 선택 ';
+ workplaces.forEach(function(wp) {
+ html += '' +
+ esc(wp.workplace_name) +
+ ' ';
+ });
+ if (workplaces.length === 0) {
+ html += '등록된 장소가 없습니다 ';
+ }
+ listEl.innerHTML = html;
+ document.getElementById('pickerAddRow').style.display = 'none';
+ }
+
+ window.pickTask = function(taskId) {
+ var indices = getSelectedIndices();
+ indices.forEach(function(i) {
+ document.getElementById('de_task_' + i).value = taskId;
+ updateCardStatus(i);
+ });
+ closePicker();
+ window.showToast(indices.length + '명에게 작업 적용', 'success');
+ };
+
+ window.pickWpCategory = function(catId) {
+ renderWorkplacePicker(catId);
+ };
+
+ window.pickWorkplace = function(catId, wpId) {
+ var indices = getSelectedIndices();
+ indices.forEach(function(i) {
+ // 분류 설정
+ document.getElementById('de_wpcat_' + i).value = catId;
+ // 장소 옵션 갱신
+ var wpSel = document.getElementById('de_wp_' + i);
+ wpSel.innerHTML = '';
+ if (deWpMap[catId]) {
+ deWpMap[catId].forEach(function(wp) {
+ wpSel.innerHTML += '';
+ });
+ }
+ wpSel.value = wpId;
+ updateCardStatus(i);
+ });
+ closePicker();
+ window.showToast(indices.length + '명에게 장소 적용', 'success');
+ };
+
+ // ─── 새 작업/공정 추가 ───
+
+ async function addNewTask() {
+ var name = document.getElementById('pickerAddInput').value.trim();
+ if (!name) { window.showToast('작업명을 입력하세요.', 'error'); return; }
+
+ var workTypeId = deSession.work_type_id || (deMembers[0] && deMembers[0].work_type_id) || null;
+ try {
+ var res = await window.TbmAPI.createTask({
+ task_name: name,
+ work_type_id: workTypeId
+ });
+ if (res && res.success) {
+ var newId = res.data.task_id;
+ // deTasks에 추가
+ deTasks.push({ task_id: newId, task_name: name, work_type_id: workTypeId });
+ // 모든 작업자 드롭다운 갱신
+ for (var i = 0; i < deMembers.length; i++) {
+ var sel = document.getElementById('de_task_' + i);
+ var opt = document.createElement('option');
+ opt.value = newId;
+ opt.textContent = name;
+ sel.appendChild(opt);
+ }
+ // 피커 다시 렌더링
+ renderTaskPicker();
+ window.showToast('작업 "' + name + '" 추가됨', 'success');
+ } else {
+ window.showToast('작업 추가 실패', 'error');
+ }
+ } catch(e) {
+ console.error(e);
+ window.showToast('오류가 발생했습니다.', 'error');
+ }
+ }
+
+ window.closeDetailEditSheet = function() {
+ document.getElementById('detailEditOverlay').style.display = 'none';
+ document.getElementById('detailEditSheet').style.display = 'none';
+ clearBusy('detailEdit');
+ };
+
+ // 저장 (부분 입력도 허용)
+ window.saveDetailEdit = async function() {
+ var members = [];
+ for (var i = 0; i < deMembers.length; i++) {
+ var m = deMembers[i];
+ var taskId = document.getElementById('de_task_' + i).value || null;
+ var wpCatId = document.getElementById('de_wpcat_' + i).value || null;
+ var wpId = document.getElementById('de_wp_' + i).value || null;
+
+ members.push({
+ worker_id: m.worker_id,
+ project_id: m.project_id || deSession.project_id || null,
+ work_type_id: m.work_type_id || deSession.work_type_id || null,
+ task_id: taskId ? parseInt(taskId) : null,
+ workplace_category_id: wpCatId ? parseInt(wpCatId) : null,
+ workplace_id: wpId ? parseInt(wpId) : null,
+ work_detail: m.work_detail || null
+ });
+ }
+
+ var btn = document.getElementById('deSaveBtn');
+ btn.disabled = true;
+ btn.textContent = '저장 중...';
+
+ try {
+ await window.TbmAPI.clearTeamMembers(deSessionId);
+ var res = await window.TbmAPI.addTeamMembers(deSessionId, members);
+ if (res && res.success) {
+ closeDetailEditSheet();
+ window.showToast('세부 내역이 저장되었습니다.', 'success');
+ await loadData();
+ } else {
+ window.showToast('저장에 실패했습니다.', 'error');
+ }
+ } catch(e) {
+ console.error('세부 편집 저장 오류:', e);
+ window.showToast('오류가 발생했습니다.', 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '저장';
+ }
+ };
+
+ // 완료 (미입력 있으면 차단)
+ window.completeFromDetailSheet = function() {
+ var incomplete = [];
+ for (var i = 0; i < deMembers.length; i++) {
+ var taskVal = document.getElementById('de_task_' + i).value;
+ var wpVal = document.getElementById('de_wp_' + i).value;
+ if (!taskVal || !wpVal) {
+ incomplete.push(deMembers[i].worker_name);
+ }
+ }
+ if (incomplete.length > 0) {
+ window.showToast('미입력: ' + incomplete.join(', '), 'error');
+ return;
+ }
+ var sid = deSessionId;
+ saveDetailEdit().then(function() {
+ window.completeTbm(sid);
+ });
+ };
+
+ window.deleteFromDetailSheet = function() {
+ var sid = deSessionId;
+ closeDetailEditSheet();
+ window.deleteTbm(sid);
+ };
+
+ // ─── TBM 완료 바텀시트 ───
+
+ var completeSessionId = null;
+ var completeTeamMembers = [];
+
+ window.completeTbm = async function(sid) {
+ if (isBusy('complete')) return;
+ setBusy('complete');
+ showLoading('확인 중...');
+ completeSessionId = sid;
+ try {
+ completeTeamMembers = await window.TbmAPI.getTeamMembers(sid).catch(function() { return []; });
+ if (completeTeamMembers.length === 0) {
+ window.showToast('팀원이 없습니다.', 'error');
+ return;
+ }
+
+ // 세부 미입력 작업자 체크
+ var incomplete = completeTeamMembers.filter(function(m) { return !m.task_id || !m.workplace_id; });
+ if (incomplete.length > 0) {
+ var names = incomplete.map(function(m) { return m.worker_name; }).join(', ');
+ window.showToast('세부 미입력: ' + names + ' - 세부 내역을 먼저 입력하세요.', 'error');
+ return;
+ }
+
+ renderCompleteSheet();
+ document.getElementById('completeOverlay').style.display = 'block';
+ document.getElementById('completeSheet').style.display = 'block';
+ } catch(e) {
+ console.error(e);
+ window.showToast('팀원 조회 중 오류가 발생했습니다.', 'error');
+ } finally {
+ hideLoading();
+ clearBusy('complete');
+ }
+ };
+
+ function renderCompleteSheet() {
+ var html = '';
+ completeTeamMembers.forEach(function(m, i) {
+ html += '' +
+ ' ' +
+ ' ' + esc(m.worker_name) + ' (' + esc(m.job_type || '') + ') ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ '' +
+ ' ' +
+ ' ';
+ });
+ document.getElementById('completeWorkerList').innerHTML = html;
+ }
+
+ window.onAttTypeChange = function(idx) {
+ var sel = document.getElementById('att_type_' + idx);
+ var inp = document.getElementById('att_hours_' + idx);
+ var hint = document.getElementById('att_hint_' + idx);
+ var val = sel.value;
+ if (val === 'overtime') {
+ inp.style.display = 'block'; inp.placeholder = '+시간'; inp.value = ''; hint.textContent = '';
+ } else if (val === 'early') {
+ inp.style.display = 'block'; inp.placeholder = '근무시간'; inp.value = ''; hint.textContent = '';
+ } else {
+ inp.style.display = 'none'; inp.value = '';
+ var labels = { regular:'8h', annual:'연차 자동처리', half:'4h', quarter:'6h' };
+ hint.textContent = labels[val] || '';
+ }
+ };
+
+ window.closeCompleteSheet = function() {
+ document.getElementById('completeOverlay').style.display = 'none';
+ document.getElementById('completeSheet').style.display = 'none';
+ };
+
+ window.submitCompleteSheet = async function() {
+ var attendanceData = [];
+ for (var i = 0; i < completeTeamMembers.length; i++) {
+ var type = document.getElementById('att_type_' + i).value;
+ var hoursVal = document.getElementById('att_hours_' + i).value;
+ var hours = hoursVal ? parseFloat(hoursVal) : null;
+ if (type === 'overtime' && (!hours || hours <= 0)) {
+ window.showToast(esc(completeTeamMembers[i].worker_name) + '의 추가 시간을 입력해주세요.', 'error'); return;
+ }
+ if (type === 'early' && (!hours || hours <= 0)) {
+ window.showToast(esc(completeTeamMembers[i].worker_name) + '의 근무 시간을 입력해주세요.', 'error'); return;
+ }
+ attendanceData.push({ worker_id: completeTeamMembers[i].worker_id, attendance_type: type, attendance_hours: hours });
+ }
+
+ var btn = document.getElementById('completeSheetBtn');
+ btn.disabled = true; btn.textContent = '처리 중...';
+
+ try {
+ var now = new Date();
+ var endTime = String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');
+ var res = await window.apiCall('/tbm/sessions/' + completeSessionId + '/complete', 'POST', {
+ end_time: endTime, attendance_data: attendanceData
+ });
+ if (res && res.success) {
+ closeCompleteSheet();
+ window.showToast('TBM이 완료 처리되었습니다.', 'success');
+ await loadData();
+ } else {
+ window.showToast('완료 처리에 실패했습니다.', 'error');
+ }
+ } catch(e) {
+ console.error(e);
+ window.showToast('오류가 발생했습니다.', 'error');
+ } finally {
+ btn.disabled = false; btn.textContent = '완료 처리';
+ }
+ };
+
+ window.deleteTbm = async function(sid) {
+ if (!confirm('이 TBM을 삭제하시겠습니까?')) return;
+ try {
+ var res = await window.TbmAPI.deleteSession(sid);
+ if (res && res.success) {
+ window.showToast('TBM이 삭제되었습니다.', 'success');
+ await loadData();
+ } else {
+ window.showToast('삭제에 실패했습니다.', 'error');
+ }
+ } catch(e) {
+ window.showToast('오류가 발생했습니다.', 'error');
+ }
+ };
+
+ // ─── 분할 기능 ───
+
+ var splitMemberIdx = null;
+ var splitOption = 'keep'; // 'keep' | 'send'
+ var splitTargetSessionId = null;
+ var cachedProjects = null;
+ var cachedWorkTypes = null;
+
+ // 프로젝트/공정 목록 로딩 (캐시)
+ async function loadProjectsAndWorkTypes() {
+ if (!cachedProjects) {
+ try {
+ cachedProjects = await window.TbmAPI.loadProjects() || [];
+ } catch(e) { cachedProjects = []; }
+ }
+ if (!cachedWorkTypes) {
+ try {
+ cachedWorkTypes = await window.TbmAPI.loadWorkTypes() || [];
+ } catch(e) { cachedWorkTypes = []; }
+ }
+ }
+
+ function populateProjectSelect(selectId, currentProjectId) {
+ var sel = document.getElementById(selectId);
+ var html = '';
+ (cachedProjects || []).forEach(function(p) {
+ html += '';
+ });
+ sel.innerHTML = html;
+ }
+
+ function populateWorkTypeSelect(selectId, currentWorkTypeId) {
+ var sel = document.getElementById(selectId);
+ var html = '';
+ (cachedWorkTypes || []).forEach(function(wt) {
+ html += '';
+ });
+ sel.innerHTML = html;
+ }
+
+ window.openSplitSheet = async function(memberIdx) {
+ if (isBusy('split')) return;
+ setBusy('split');
+ showLoading('불러오는 중...');
+ splitMemberIdx = memberIdx;
+ splitOption = 'keep';
+ splitTargetSessionId = null;
+ var m = deMembers[memberIdx];
+ var currentHours = m.work_hours === null || m.work_hours === undefined ? 8 : parseFloat(m.work_hours);
+
+ document.getElementById('splitTitle').textContent = esc(m.worker_name) + ' 작업 분할';
+ document.getElementById('splitSubtitle').textContent = '현재 ' + currentHours + 'h 배정';
+ document.getElementById('splitHours').value = '';
+ document.getElementById('splitHours').max = currentHours - 0.5;
+ document.getElementById('splitRemainder').textContent = '';
+ document.getElementById('splitOptKeep').className = 'split-radio-item active';
+ document.getElementById('splitOptSend').className = 'split-radio-item';
+ document.getElementById('splitSessionPicker').style.display = 'none';
+
+ // 시간 입력 시 나머지 자동 계산
+ document.getElementById('splitHours').oninput = function() {
+ var val = parseFloat(this.value);
+ if (val && val > 0 && val < currentHours) {
+ document.getElementById('splitRemainder').textContent = '나머지: ' + (currentHours - val) + 'h';
+ } else {
+ document.getElementById('splitRemainder').textContent = '';
+ }
+ };
+
+ // 프로젝트/공정 목록 로드 + 드롭다운 채우기
+ await loadProjectsAndWorkTypes();
+ populateProjectSelect('splitProjectId', null);
+ populateWorkTypeSelect('splitWorkTypeId', null);
+
+ // 다른 세션 목록 로드 (당일)
+ loadSplitSessionList();
+
+ document.getElementById('splitOverlay').style.display = 'block';
+ document.getElementById('splitSheet').style.display = 'block';
+ hideLoading();
+ clearBusy('split');
+ };
+
+ async function loadSplitSessionList() {
+ var todayStr = getTodayStr();
+ try {
+ var sessions = await window.TbmAPI.fetchSessionsByDate(todayStr);
+ if (sessions && sessions.length > 0) {
+ var html = '';
+ sessions.forEach(function(s) {
+ if (s.session_id === deSessionId) return; // 현재 세션 제외
+ if (s.status !== 'draft') return; // draft만
+ var leaderName = s.leader_name || s.created_by_name || '미지정';
+ var workType = s.work_type_name || '';
+ html += '' +
+ esc(leaderName) + (workType ? ' - ' + esc(workType) : '') +
+ ' (' + (parseInt(s.team_member_count)||0) + '명)' +
+ ' ';
+ });
+ if (!html) html = '다른 TBM이 없습니다 ';
+ document.getElementById('splitSessionList').innerHTML = html;
+ }
+ } catch(e) {
+ console.error(e);
+ }
+ }
+
+ window.setSplitOption = function(opt) {
+ splitOption = opt;
+ splitTargetSessionId = null;
+ document.getElementById('splitOptKeep').className = 'split-radio-item' + (opt === 'keep' ? ' active' : '');
+ document.getElementById('splitOptSend').className = 'split-radio-item' + (opt === 'send' ? ' active' : '');
+ document.getElementById('splitSessionPicker').style.display = opt === 'send' ? 'block' : 'none';
+ // 세션 선택 초기화
+ document.querySelectorAll('.split-session-item').forEach(function(el) { el.classList.remove('active'); });
+ };
+
+ window.selectSplitSession = function(sid) {
+ splitTargetSessionId = sid;
+ document.querySelectorAll('.split-session-item').forEach(function(el) {
+ el.classList.toggle('active', parseInt(el.dataset.sid) === sid);
+ });
+ };
+
+ window.closeSplitSheet = function() {
+ document.getElementById('splitOverlay').style.display = 'none';
+ document.getElementById('splitSheet').style.display = 'none';
+ clearBusy('split');
+ };
+
+ window.saveSplit = async function() {
+ var m = deMembers[splitMemberIdx];
+ var currentHours = m.work_hours === null || m.work_hours === undefined ? 8 : parseFloat(m.work_hours);
+ var splitHours = parseFloat(document.getElementById('splitHours').value);
+
+ if (!splitHours || splitHours <= 0 || splitHours >= currentHours) {
+ window.showToast('올바른 시간을 입력하세요 (0 < 시간 < ' + currentHours + ')', 'error');
+ return;
+ }
+
+ var btn = document.getElementById('splitSaveBtn');
+ btn.disabled = true;
+ btn.textContent = '처리 중...';
+
+ try {
+ // 프로젝트/공정 선택값
+ var selProjectId = document.getElementById('splitProjectId').value;
+ var selWorkTypeId = document.getElementById('splitWorkTypeId').value;
+
+ if (splitOption === 'keep') {
+ var remainHoursKeep = currentHours - splitHours;
+ var newProjectId = selProjectId ? parseInt(selProjectId) : (m.project_id || null);
+ var newWorkTypeId = selWorkTypeId ? parseInt(selWorkTypeId) : (m.work_type_id || null);
+
+ // 1) 기존 항목: 시간만 줄이기 (프로젝트/공정 유지)
+ await window.TbmAPI.updateTeamMember(deSessionId, {
+ worker_id: m.worker_id,
+ project_id: m.project_id || null,
+ work_type_id: m.work_type_id || null,
+ task_id: m.task_id || null,
+ workplace_category_id: m.workplace_category_id || null,
+ workplace_id: m.workplace_id || null,
+ work_detail: m.work_detail || null,
+ is_present: true,
+ work_hours: splitHours
+ });
+
+ // 2) 나머지 시간으로 새 항목 추가 (프로젝트/공정 변경 가능)
+ await window.TbmAPI.splitAssignment(deSessionId, {
+ worker_id: m.worker_id,
+ work_hours: remainHoursKeep,
+ project_id: newProjectId,
+ work_type_id: newWorkTypeId
+ });
+
+ closeSplitSheet();
+ // 세부 편집 데이터 다시 로드
+ deMembers = await window.TbmAPI.getTeamMembers(deSessionId).catch(function() { return deMembers; });
+ renderDetailEditSheet();
+ window.showToast('분할 완료: ' + splitHours + 'h + ' + remainHoursKeep + 'h', 'success');
+ } else if (splitOption === 'send') {
+ if (!splitTargetSessionId) {
+ window.showToast('이동할 TBM을 선택하세요.', 'error');
+ btn.disabled = false;
+ btn.textContent = '분할 저장';
+ return;
+ }
+ var remainHours = currentHours - splitHours;
+ var destProjectId = selProjectId ? parseInt(selProjectId) : (m.project_id || null);
+ var destWorkTypeId = selWorkTypeId ? parseInt(selWorkTypeId) : (m.work_type_id || null);
+ // transfer API 호출
+ var res = await window.TbmAPI.transfer({
+ transfer_type: 'send',
+ worker_id: m.worker_id,
+ source_session_id: deSessionId,
+ dest_session_id: splitTargetSessionId,
+ hours: remainHours,
+ project_id: destProjectId,
+ work_type_id: destWorkTypeId
+ });
+
+ if (res && res.success) {
+ closeSplitSheet();
+ closeDetailEditSheet();
+ window.showToast('이동 완료' + (res.data && res.data.warning ? ' (' + res.data.warning + ')' : ''), 'success');
+ await loadData();
+ } else {
+ window.showToast(res?.message || '이동 실패', 'error');
+ }
+ }
+ } catch(e) {
+ console.error('분할 오류:', e);
+ window.showToast('오류가 발생했습니다.', 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '분할 저장';
+ }
+ };
+
+ // ─── 빼오기 기능 ───
+
+ var pullSessionId = null;
+ var pullMembers = [];
+ var pullWorker = null; // 빼오기 대상
+ var myDraftSession = null; // 내 draft TBM
+
+ window.openPullSheet = async function(sid) {
+ if (isBusy('pull')) return;
+ setBusy('pull');
+ showLoading('불러오는 중...');
+ pullSessionId = sid;
+ try {
+ pullMembers = await window.TbmAPI.getTeamMembers(sid).catch(function() { return []; });
+
+ var session = await window.TbmAPI.getSession(sid).catch(function() { return null; });
+ var leaderName = session ? (session.leader_name || session.created_by_name || '미지정') : '미지정';
+
+ document.getElementById('pullTitle').textContent = esc(leaderName) + ' 반장 팀';
+ document.getElementById('pullSubtitle').textContent = pullMembers.length + '명 배정';
+
+ // 내 draft TBM 확인
+ myDraftSession = todaySessions.find(function(s) {
+ return isMySession(s) && s.status === 'draft';
+ });
+
+ var html = '';
+ pullMembers.forEach(function(m) {
+ var hours = m.work_hours === null || m.work_hours === undefined ? 8 : parseFloat(m.work_hours);
+ var hoursText = hours + 'h';
+
+ var btnHtml = '';
+ if (!myDraftSession) {
+ btnHtml = '';
+ } else {
+ btnHtml = '';
+ }
+
+ html += '' +
+ ' ' +
+ ' ' + esc(m.worker_name) + ' ' + hoursText + ' ' +
+ ' ' + esc(m.job_type || '') + ' ' +
+ ' ' +
+ btnHtml +
+ ' ';
+ });
+
+ if (pullMembers.length === 0) {
+ html = '팀원이 없습니다 ';
+ }
+
+ document.getElementById('pullMemberList').innerHTML = html;
+ document.getElementById('pullOverlay').style.display = 'block';
+ document.getElementById('pullSheet').style.display = 'block';
+ } catch(e) {
+ console.error('빼오기 로드 오류:', e);
+ window.showToast('데이터를 불러올 수 없습니다.', 'error');
+ } finally {
+ hideLoading();
+ clearBusy('pull');
+ }
+ };
+
+ window.closePullSheet = function() {
+ document.getElementById('pullOverlay').style.display = 'none';
+ document.getElementById('pullSheet').style.display = 'none';
+ clearBusy('pull');
+ };
+
+ window.startPull = async function(workerId, workerName, maxHours) {
+ pullWorker = { worker_id: workerId, worker_name: workerName, max_hours: maxHours };
+ document.getElementById('pullHoursTitle').textContent = esc(workerName) + ' 빼오기';
+ document.getElementById('pullHoursSubtitle').textContent = '최대 ' + maxHours + 'h 가능';
+ document.getElementById('pullHoursInput').value = maxHours;
+ document.getElementById('pullHoursInput').max = maxHours;
+
+ // 프로젝트/공정 드롭다운 채우기
+ await loadProjectsAndWorkTypes();
+ var myProject = myDraftSession ? myDraftSession.project_id : null;
+ var myWorkType = myDraftSession ? myDraftSession.work_type_id : null;
+ populateProjectSelect('pullProjectId', myProject);
+ populateWorkTypeSelect('pullWorkTypeId', myWorkType);
+
+ document.getElementById('pullHoursOverlay').style.display = 'block';
+ document.getElementById('pullHoursSheet').style.display = 'block';
+ };
+
+ window.closePullHoursModal = function() {
+ document.getElementById('pullHoursOverlay').style.display = 'none';
+ document.getElementById('pullHoursSheet').style.display = 'none';
+ };
+
+ window.confirmPull = async function() {
+ var hours = parseFloat(document.getElementById('pullHoursInput').value);
+ if (!hours || hours <= 0 || hours > pullWorker.max_hours) {
+ window.showToast('올바른 시간을 입력하세요 (0 < 시간 <= ' + pullWorker.max_hours + ')', 'error');
+ return;
+ }
+
+ var btn = document.getElementById('pullHoursSaveBtn');
+ btn.disabled = true;
+ btn.textContent = '처리 중...';
+
+ try {
+ var pullProjectId = document.getElementById('pullProjectId').value || null;
+ var pullWorkTypeId = document.getElementById('pullWorkTypeId').value || null;
+ var res = await window.TbmAPI.transfer({
+ transfer_type: 'pull',
+ worker_id: pullWorker.worker_id,
+ source_session_id: pullSessionId,
+ dest_session_id: myDraftSession.session_id,
+ hours: hours,
+ project_id: pullProjectId ? parseInt(pullProjectId) : null,
+ work_type_id: pullWorkTypeId ? parseInt(pullWorkTypeId) : null
+ });
+
+ if (res && res.success) {
+ closePullHoursModal();
+ closePullSheet();
+ window.showToast(esc(pullWorker.worker_name) + ' ' + hours + 'h 빼오기 완료' +
+ (res.data && res.data.warning ? ' (' + res.data.warning + ')' : ''), 'success');
+ await loadData();
+ } else {
+ window.showToast(res?.message || '빼오기 실패', 'error');
+ }
+ } catch(e) {
+ console.error('빼오기 오류:', e);
+ window.showToast('오류가 발생했습니다.', 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '빼오기 실행';
+ }
+ };
+
+ // ─── 인계 바텀시트 ───
+
+ var handoverSessionId = null;
+ var handoverSession = null;
+
+ window.handoverFromDetailSheet = function() {
+ var sid = deSessionId;
+ closeDetailEditSheet();
+ openHandoverSheet(sid);
+ };
+
+ async function openHandoverSheet(sid) {
+ if (isBusy('handover')) return;
+ setBusy('handover');
+ showLoading('인계 정보 불러오는 중...');
+ handoverSessionId = sid;
+
+ try {
+ var API = window.TbmAPI;
+ var results = await Promise.all([
+ API.getSession(sid).catch(function() { return null; }),
+ API.getTeamMembers(sid).catch(function() { return []; }),
+ API.loadWorkers().catch(function() { return []; })
+ ]);
+
+ handoverSession = results[0];
+ var team = results[1];
+ var workers = results[2];
+
+ if (!handoverSession) {
+ window.showToast('세션 정보를 불러올 수 없습니다.', 'error');
+ return;
+ }
+
+ // 현재 세션 리더를 제외한 반장/그룹장 목록
+ var leaders = workers.filter(function(w) {
+ return (w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin') &&
+ w.worker_id !== handoverSession.leader_id;
+ });
+
+ var leaderSelect = document.getElementById('handoverLeaderId');
+ leaderSelect.innerHTML = '' +
+ leaders.map(function(w) {
+ return '';
+ }).join('');
+
+ // 인계할 팀원 체크리스트
+ var listEl = document.getElementById('handoverWorkerList');
+ if (team.length === 0) {
+ listEl.innerHTML = '팀원이 없습니다. ';
+ } else {
+ listEl.innerHTML = team.map(function(m) {
+ return '';
+ }).join('');
+ }
+
+ document.getElementById('handoverNotes').value = '';
+
+ document.getElementById('handoverOverlay').style.display = 'block';
+ document.getElementById('handoverSheet').style.display = 'block';
+ } catch(e) {
+ console.error('인계 시트 열기 오류:', e);
+ window.showToast('인계 정보를 불러오는 중 오류가 발생했습니다.', 'error');
+ } finally {
+ hideLoading();
+ clearBusy('handover');
+ }
+ }
+ window.openHandoverSheet = openHandoverSheet;
+
+ window.closeHandoverSheet = function() {
+ document.getElementById('handoverOverlay').style.display = 'none';
+ document.getElementById('handoverSheet').style.display = 'none';
+ };
+
+ window.submitHandover = async function() {
+ var toLeaderId = parseInt(document.getElementById('handoverLeaderId').value);
+ var notes = document.getElementById('handoverNotes').value;
+
+ if (!toLeaderId) {
+ window.showToast('인계 대상 반장을 선택해주세요.', 'error');
+ return;
+ }
+
+ var workerIds = [];
+ document.querySelectorAll('.handover-worker-cb:checked').forEach(function(cb) {
+ workerIds.push(parseInt(cb.value));
+ });
+
+ if (workerIds.length === 0) {
+ window.showToast('인계할 팀원을 최소 1명 선택해주세요.', 'error');
+ return;
+ }
+
+ var btn = document.querySelector('#handoverSheet .split-btn');
+ btn.disabled = true;
+ btn.textContent = '처리 중...';
+
+ try {
+ var today = getTodayStr();
+ var now = new Date().toTimeString().slice(0, 5);
+
+ var handoverData = {
+ session_id: handoverSessionId,
+ from_leader_id: handoverSession.leader_id,
+ to_leader_id: toLeaderId,
+ handover_date: today,
+ handover_time: now,
+ reason: '모바일 인계',
+ handover_notes: notes,
+ worker_ids: workerIds
+ };
+
+ var res = await window.TbmAPI.saveHandover(handoverData);
+ if (res && res.success) {
+ window.closeHandoverSheet();
+ window.showToast('작업 인계가 요청되었습니다.', 'success');
+ await loadData();
+ } else {
+ window.showToast(res?.message || '인계 요청에 실패했습니다.', 'error');
+ }
+ } catch(e) {
+ console.error('인계 저장 오류:', e);
+ window.showToast('인계 중 오류가 발생했습니다.', 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '인계 요청';
+ }
+ };
+})();
diff --git a/system1-factory/web/js/tbm/index.js b/system1-factory/web/js/tbm/index.js
deleted file mode 100644
index a295074..0000000
--- a/system1-factory/web/js/tbm/index.js
+++ /dev/null
@@ -1,325 +0,0 @@
-/**
- * TBM - Module Loader
- * TBM 모듈을 초기화하고 연결하는 메인 진입점
- *
- * 로드 순서:
- * 1. state.js - 전역 상태 관리
- * 2. utils.js - 유틸리티 함수
- * 3. api.js - API 클라이언트
- * 4. index.js - 이 파일 (메인 컨트롤러)
- */
-
-class TbmController {
- constructor() {
- this.state = window.TbmState;
- this.api = window.TbmAPI;
- this.utils = window.TbmUtils;
- this.initialized = false;
-
- console.log('[TbmController] 생성');
- }
-
- /**
- * 초기화
- */
- async init() {
- if (this.initialized) {
- console.log('[TbmController] 이미 초기화됨');
- return;
- }
-
- console.log('🛠️ TBM 관리 페이지 초기화');
-
- // API 함수가 로드될 때까지 대기
- let retryCount = 0;
- while (!window.apiCall && retryCount < 50) {
- await new Promise(resolve => setTimeout(resolve, 100));
- retryCount++;
- }
-
- if (!window.apiCall) {
- window.showToast?.('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
- return;
- }
-
- // 오늘 날짜 설정 (서울 시간대 기준)
- const today = this.utils.getTodayKST();
- const tbmDateEl = document.getElementById('tbmDate');
- const sessionDateEl = document.getElementById('sessionDate');
- if (tbmDateEl) tbmDateEl.value = today;
- if (sessionDateEl) sessionDateEl.value = today;
-
- // 이벤트 리스너 설정
- this.setupEventListeners();
-
- // 초기 데이터 로드
- await this.api.loadInitialData();
- await this.api.loadTodayOnlyTbm();
-
- // 렌더링
- this.displayTodayTbmSessions();
-
- this.initialized = true;
- console.log('[TbmController] 초기화 완료');
- }
-
- /**
- * 이벤트 리스너 설정
- */
- setupEventListeners() {
- // 탭 버튼들
- document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- const tabName = btn.dataset.tab;
- if (tabName) this.switchTbmTab(tabName);
- });
- });
- }
-
- /**
- * 탭 전환
- */
- async switchTbmTab(tabName) {
- this.state.setCurrentTab(tabName);
-
- // 탭 버튼 활성화 상태 변경
- document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
- if (btn.dataset.tab === tabName) {
- btn.classList.add('active');
- } else {
- btn.classList.remove('active');
- }
- });
-
- // 탭 컨텐츠 표시 변경
- document.querySelectorAll('.tbm-tab-content').forEach(content => {
- content.classList.remove('active');
- });
- const tabContent = document.getElementById(`${tabName}-tab`);
- if (tabContent) tabContent.classList.add('active');
-
- // 탭에 따라 데이터 로드
- if (tabName === 'tbm-input') {
- await this.api.loadTodayOnlyTbm();
- this.displayTodayTbmSessions();
- } else if (tabName === 'tbm-manage') {
- await this.api.loadRecentTbmGroupedByDate();
- this.displayTbmGroupedByDate();
- this.updateViewModeIndicator();
- }
- }
-
- /**
- * 오늘의 TBM 세션 표시
- */
- 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');
-
- const sessions = this.state.todaySessions;
-
- if (sessions.length === 0) {
- if (grid) grid.innerHTML = '';
- if (emptyState) emptyState.style.display = 'flex';
- if (todayTotalEl) todayTotalEl.textContent = '0';
- if (todayCompletedEl) todayCompletedEl.textContent = '0';
- if (todayActiveEl) todayActiveEl.textContent = '0';
- return;
- }
-
- if (emptyState) emptyState.style.display = 'none';
-
- const completedCount = sessions.filter(s => s.status === 'completed').length;
- const activeCount = sessions.filter(s => s.status === 'draft').length;
-
- if (todayTotalEl) todayTotalEl.textContent = sessions.length;
- if (todayCompletedEl) todayCompletedEl.textContent = completedCount;
- if (todayActiveEl) todayActiveEl.textContent = activeCount;
-
- if (grid) {
- grid.innerHTML = sessions.map(session => this.createSessionCard(session)).join('');
- }
- }
-
- /**
- * 날짜별 그룹으로 TBM 표시
- */
- 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(this.state.dateGroupedSessions).sort((a, b) =>
- new Date(b) - new Date(a)
- );
-
- if (sortedDates.length === 0 || this.state.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 = this.state.allLoadedSessions.filter(s => s.status === 'completed').length;
- if (totalSessionsEl) totalSessionsEl.textContent = this.state.allLoadedSessions.length;
- if (completedSessionsEl) completedSessionsEl.textContent = completedCount;
-
- // 날짜별 그룹 HTML 생성
- const today = this.utils.getTodayKST();
- const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
-
- container.innerHTML = sortedDates.map(date => {
- const sessions = this.state.dateGroupedSessions[date];
- const dateObj = new Date(date + 'T00:00:00');
- const dayName = dayNames[dateObj.getDay()];
- const isToday = date === today;
-
- const [year, month, day] = date.split('-');
- const displayDate = `${parseInt(month)}월 ${parseInt(day)}일`;
-
- return `
-
-
-
-
- ${sessions.map(session => this.createSessionCard(session)).join('')}
-
-
-
- `;
- }).join('');
- }
-
- /**
- * 뷰 모드 표시 업데이트
- */
- updateViewModeIndicator() {
- const indicator = document.getElementById('viewModeIndicator');
- const text = document.getElementById('viewModeText');
-
- if (indicator && text) {
- if (this.state.isAdminUser()) {
- indicator.style.display = 'none';
- } else {
- indicator.style.display = 'inline-flex';
- text.textContent = '내 TBM';
- }
- }
- }
-
- /**
- * TBM 세션 카드 생성
- */
- createSessionCard(session) {
- const statusBadge = this.utils.getStatusBadge(session.status);
-
- const leaderName = session.leader_name || session.created_by_name || '작업 책임자';
- const leaderRole = session.leader_name
- ? (session.leader_job_type || '작업자')
- : '관리자';
-
- return `
-
-
-
-
-
-
- 프로젝트
- ${session.project_name || '-'}
-
-
- 공정
- ${session.work_type_name || '-'}
-
-
- 작업장
- ${session.work_location || '-'}
-
-
- 팀원
- ${session.team_member_count || 0}명
-
-
-
-
- ${session.status === 'draft' ? `
-
- ` : ''}
-
- `;
- }
-
- /**
- * 디버그
- */
- debug() {
- console.log('[TbmController] 상태 디버그:');
- this.state.debug();
- }
-}
-
-// 전역 인스턴스 생성
-window.TbmController = new TbmController();
-
-// 하위 호환성: 기존 전역 함수들
-window.switchTbmTab = (tabName) => window.TbmController.switchTbmTab(tabName);
-window.displayTodayTbmSessions = () => window.TbmController.displayTodayTbmSessions();
-window.displayTbmGroupedByDate = () => window.TbmController.displayTbmGroupedByDate();
-window.displayTbmSessions = () => window.TbmController.displayTbmGroupedByDate();
-window.createSessionCard = (session) => window.TbmController.createSessionCard(session);
-window.updateViewModeIndicator = () => window.TbmController.updateViewModeIndicator();
-
-// 날짜 그룹 토글
-window.toggleDateGroup = function(date) {
- const group = document.querySelector(`.tbm-date-group[data-date="${date}"]`);
- if (group) {
- group.classList.toggle('collapsed');
- }
-};
-
-// DOMContentLoaded 이벤트에서 초기화
-document.addEventListener('DOMContentLoaded', () => {
- setTimeout(() => {
- window.TbmController.init();
- }, 100);
-});
-
-console.log('[Module] tbm/index.js 로드 완료');
diff --git a/system1-factory/web/js/tbm/state.js b/system1-factory/web/js/tbm/state.js
index e2d9a9d..925cc2f 100644
--- a/system1-factory/web/js/tbm/state.js
+++ b/system1-factory/web/js/tbm/state.js
@@ -1,10 +1,12 @@
/**
* TBM - State Manager
- * TBM 페이지의 전역 상태 관리
+ * TBM 페이지의 전역 상태 관리 (BaseState 상속)
*/
-class TbmState {
+class TbmState extends BaseState {
constructor() {
+ super();
+
// 세션 데이터
this.allSessions = [];
this.todaySessions = [];
@@ -48,56 +50,9 @@ class TbmState {
this.mapImage = null;
this.mapRegions = [];
- // 리스너
- this.listeners = new Map();
-
console.log('[TbmState] 초기화 완료');
}
- /**
- * 상태 업데이트
- */
- update(key, value) {
- const prevValue = this[key];
- this[key] = value;
- this.notifyListeners(key, value, prevValue);
- }
-
- /**
- * 리스너 등록
- */
- subscribe(key, callback) {
- if (!this.listeners.has(key)) {
- this.listeners.set(key, []);
- }
- this.listeners.get(key).push(callback);
- }
-
- /**
- * 리스너 알림
- */
- notifyListeners(key, newValue, prevValue) {
- const keyListeners = this.listeners.get(key) || [];
- keyListeners.forEach(callback => {
- try {
- callback(newValue, prevValue);
- } catch (error) {
- console.error(`[TbmState] 리스너 오류 (${key}):`, error);
- }
- });
- }
-
- /**
- * 현재 사용자 정보 가져오기
- */
- getUser() {
- if (!this.currentUser) {
- const userInfo = localStorage.getItem('sso_user');
- this.currentUser = userInfo ? JSON.parse(userInfo) : null;
- }
- return this.currentUser;
- }
-
/**
* Admin 여부 확인
*/
@@ -135,7 +90,7 @@ class TbmState {
*/
createEmptyTaskLine() {
return {
- task_line_id: this.generateUUID(),
+ task_line_id: window.CommonUtils.generateUUID(),
project_id: null,
work_type_id: null,
task_id: null,
@@ -207,7 +162,7 @@ class TbmState {
this.allLoadedSessions = [];
sessions.forEach(session => {
- const date = this.formatDate(session.session_date);
+ const date = window.CommonUtils.formatDate(session.session_date);
if (!this.dateGroupedSessions[date]) {
this.dateGroupedSessions[date] = [];
}
@@ -216,32 +171,6 @@ class TbmState {
});
}
- /**
- * 날짜 포맷팅
- */
- formatDate(dateString) {
- if (!dateString) return '';
- if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
- return dateString;
- }
- 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}`;
- }
-
- /**
- * UUID 생성
- */
- generateUUID() {
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
- const r = Math.random() * 16 | 0;
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
- }
-
/**
* 상태 초기화
*/
diff --git a/system1-factory/web/js/tbm/utils.js b/system1-factory/web/js/tbm/utils.js
index c0ff8c4..ef1c90a 100644
--- a/system1-factory/web/js/tbm/utils.js
+++ b/system1-factory/web/js/tbm/utils.js
@@ -1,46 +1,23 @@
/**
* TBM - Utilities
- * TBM 관련 유틸리티 함수들
+ * TBM 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임)
*/
class TbmUtils {
constructor() {
+ this._common = window.CommonUtils;
console.log('[TbmUtils] 초기화 완료');
}
- /**
- * 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환
- */
- getTodayKST() {
- const now = new Date();
- const kstOffset = 9 * 60;
- const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
- const kstTime = new Date(utc + (kstOffset * 60000));
+ // --- CommonUtils 위임 ---
+ getTodayKST() { return this._common.getTodayKST(); }
+ formatDate(dateString) { return this._common.formatDate(dateString); }
+ getDayOfWeek(dateString) { return this._common.getDayOfWeek(dateString); }
+ isToday(dateString) { return this._common.isToday(dateString); }
+ generateUUID() { return this._common.generateUUID(); }
+ escapeHtml(text) { return this._common.escapeHtml(text); }
- 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 형식으로 변환
- */
- formatDate(dateString) {
- if (!dateString) return '';
-
- if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
- return dateString;
- }
-
- 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}`;
- }
+ // --- TBM 전용 ---
/**
* 날짜 표시용 포맷 (MM월 DD일)
@@ -56,30 +33,11 @@ class TbmUtils {
*/
formatDateFull(dateString) {
if (!dateString) return '';
- const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const [year, month, day] = dateString.split('-');
- const dateObj = new Date(dateString);
- const dayName = dayNames[dateObj.getDay()];
+ const dayName = this._common.getDayOfWeek(dateString);
return `${year}년 ${parseInt(month)}월 ${parseInt(day)}일 (${dayName})`;
}
- /**
- * 요일 반환
- */
- getDayOfWeek(dateString) {
- const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
- const dateObj = new Date(dateString + 'T00:00:00');
- return dayNames[dateObj.getDay()];
- }
-
- /**
- * 오늘인지 확인
- */
- isToday(dateString) {
- const today = this.getTodayKST();
- return this.formatDate(dateString) === today;
- }
-
/**
* 현재 시간을 HH:MM 형식으로 반환
*/
@@ -87,40 +45,13 @@ class TbmUtils {
return new Date().toTimeString().slice(0, 5);
}
- /**
- * UUID 생성
- */
- generateUUID() {
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
- const r = Math.random() * 16 | 0;
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
- }
-
- /**
- * HTML 이스케이프
- */
- escapeHtml(text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-
/**
* 날씨 조건명 반환
*/
getWeatherConditionName(code) {
const names = {
- clear: '맑음',
- rain: '비',
- snow: '눈',
- heat: '폭염',
- cold: '한파',
- wind: '강풍',
- fog: '안개',
- dust: '미세먼지'
+ clear: '맑음', rain: '비', snow: '눈', heat: '폭염',
+ cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지'
};
return names[code] || code;
}
@@ -130,14 +61,8 @@ class TbmUtils {
*/
getWeatherIcon(code) {
const icons = {
- clear: '☀️',
- rain: '🌧️',
- snow: '❄️',
- heat: '🔥',
- cold: '🥶',
- wind: '💨',
- fog: '🌫️',
- dust: '😷'
+ clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥',
+ cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷'
};
return icons[code] || '🌤️';
}
@@ -147,12 +72,9 @@ class TbmUtils {
*/
getCategoryName(category) {
const names = {
- 'PPE': '개인 보호 장비',
- 'EQUIPMENT': '장비 점검',
- 'ENVIRONMENT': '작업 환경',
- 'EMERGENCY': '비상 대응',
- 'WEATHER': '날씨',
- 'TASK': '작업'
+ 'PPE': '개인 보호 장비', 'EQUIPMENT': '장비 점검',
+ 'ENVIRONMENT': '작업 환경', 'EMERGENCY': '비상 대응',
+ 'WEATHER': '날씨', 'TASK': '작업'
};
return names[category] || category;
}
diff --git a/system1-factory/web/js/work-report-api.js b/system1-factory/web/js/work-report-api.js
deleted file mode 100644
index 4adc18d..0000000
--- a/system1-factory/web/js/work-report-api.js
+++ /dev/null
@@ -1,51 +0,0 @@
-// /js/work-report-api.js
-import { apiGet, apiPost } from './api-helper.js';
-
-/**
- * 작업 보고서 작성을 위해 필요한 초기 데이터(작업자, 프로젝트, 태스크)를 가져옵니다.
- * Promise.all을 사용하여 병렬로 API를 호출합니다.
- * @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
- */
-export async function getInitialData() {
- try {
- const [allWorkers, projects, tasks] = await Promise.all([
- apiGet('/workers'),
- apiGet('/projects'),
- apiGet('/tasks')
- ]);
-
- // 활성화된 작업자만 필터링
- const workers = allWorkers.filter(worker => {
- return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
- });
-
- // 데이터 형식 검증
- if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks)) {
- throw new Error('서버에서 받은 데이터 형식이 올바르지 않습니다.');
- }
-
- // 작업자 목록은 ID 기준으로 정렬
- workers.sort((a, b) => a.worker_id - b.worker_id);
-
- return { workers, projects, tasks };
- } catch (error) {
- console.error('초기 데이터 로딩 중 오류 발생:', error);
- // 에러를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함
- throw error;
- }
-}
-
-/**
- * 작성된 작업 보고서 데이터를 서버에 전송합니다.
- * @param {Array |