refactor: TBM/작업보고 코드 통합 및 API 쿼리 버그 수정

- 공통 유틸리티 추출 (common/utils.js, common/base-state.js)
- TBM 모바일 인라인 JS/CSS 외부 파일로 분리 (tbm-mobile.js, tbm-mobile.css)
- 미사용 코드 삭제 (index.js, work-report-*.js 등 5개 파일)
- TBM/작업보고 state.js, utils.js를 공통 모듈 기반으로 전환
- 작업보고서 SSO 인증 호환 수정 (token/user 함수)
- tbmModel.js: incomplete-reports 쿼리에서 users→sso_users 조인 수정, leader_name 조인 추가
- docker-compose.yml: system1-web 볼륨 마운트 추가
- 모바일 인계(handover) 기능 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-05 07:51:24 +09:00
parent 22a37ac4d9
commit 4388628788
89 changed files with 5296 additions and 5046 deletions

View File

@@ -5,41 +5,24 @@
// =================================================================
// API 설정은 api-config.js에서 window 객체에 설정됨
// 전역 변수
let workTypes = [];
let workStatusTypes = [];
let errorTypes = []; // 레거시 호환용
let issueCategories = []; // 신고 카테고리 (nonconformity)
let issueItems = []; // 신고 아이템
let workers = [];
let projects = [];
let selectedWorkers = new Set();
let workEntryCounter = 0;
let currentStep = 1;
let editingWorkId = null; // 수정 중인 작업 ID
let incompleteTbms = []; // 미완료 TBM 작업 목록
let currentTab = 'tbm'; // 현재 활성 탭
// 전역 변수 → DailyWorkReportState 프록시 사용 (state.js에서 window 프록시 정의)
// workTypes, workStatusTypes, errorTypes, issueCategories, issueItems,
// workers, projects, selectedWorkers, incompleteTbms, tempDefects,
// dailyIssuesCache, currentTab, currentStep, editingWorkId, workEntryCounter,
// currentDefectIndex, currentEditingField, currentTimeValue,
// selectedWorkplace, selectedWorkplaceName, selectedWorkplaceCategory, selectedWorkplaceCategoryName
// 부적합 원인 관리
let currentDefectIndex = null; // 현재 편집 중인 행 인덱스
let tempDefects = {}; // 임시 부적합 원인 저장 { index: [{ error_type_id, defect_hours, note }] }
// 작업장소 지도 관련 변수
let mapCanvas = null;
let mapCtx = null;
let mapImage = null;
let mapRegions = [];
let selectedWorkplace = null;
let selectedWorkplaceName = null;
let selectedWorkplaceCategory = null;
let selectedWorkplaceCategoryName = null;
// 지도 관련 변수 (프록시 아님)
var mapCanvas = null;
var mapCtx = null;
var mapImage = null;
var mapRegions = [];
// 시간 선택 관련 변수
let currentEditingField = null; // { index, type: 'total' | 'error' }
let currentTimeValue = 0;
// currentEditingField, currentTimeValue → DailyWorkReportState 프록시 사용
// 당일 신고 리마인더 관련 변수
let dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] } - 날짜별 신고 캐시
// dailyIssuesCache → DailyWorkReportState 프록시 사용
// =================================================================
// TBM 작업보고 관련 함수
@@ -182,75 +165,23 @@ function getRelatedIssues(dateStr, workplaceId, projectId) {
}
/**
* 날짜를 API 형식(YYYY-MM-DD)으로 변환 - 로컬 시간대 기준
* 날짜를 API 형식(YYYY-MM-DD)으로 변환
*/
function formatDateForApi(date) {
if (window.CommonUtils) return window.CommonUtils.formatDate(date) || null;
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
// 문자열인 경우 Date 객체로 변환
dateObj = new Date(date);
} else {
return null;
}
// 로컬 시간대 기준으로 날짜 추출 (UTC 변환 방지)
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}`;
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return null;
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
}
/**
* 사용자 정보 가져오기 (auth-check.js와 동일한 로직)
*/
function getUser() {
const user = localStorage.getItem('sso_user');
return user ? JSON.parse(user) : null;
}
/**
* 근태 유형에 따른 기본 작업시간 반환
*/
function getDefaultHoursFromAttendance(tbm) {
// work_hours가 있으면 (분할 배정) 해당 값 우선 사용
if (tbm.work_hours != null && parseFloat(tbm.work_hours) > 0) {
return parseFloat(tbm.work_hours);
}
switch (tbm.attendance_type) {
case 'overtime': return 8 + (parseFloat(tbm.attendance_hours) || 0);
case 'regular': return 8;
case 'half': return 4;
case 'quarter': return 6;
case 'early': return parseFloat(tbm.attendance_hours) || 0;
default: return 0;
}
}
/**
* 근태 유형 뱃지 HTML 반환
*/
function getAttendanceBadgeHtml(type) {
const labels = { overtime: '연장근무', regular: '정시근로', annual: '연차', half: '반차', quarter: '반반차', early: '조퇴' };
const colors = { overtime: '#7c3aed', regular: '#2563eb', annual: '#ef4444', half: '#f59e0b', quarter: '#f97316', early: '#6b7280' };
if (!type || !labels[type]) return '';
return ` <span style="display:inline-block; padding:0.125rem 0.375rem; border-radius:0.25rem; font-size:0.625rem; font-weight:700; color:white; background:${colors[type]}; vertical-align:middle; margin-left:0.25rem;">${labels[type]}</span>`;
}
/**
* 시간 표시 포맷
*/
function formatHoursDisplay(val) {
if (!val || val <= 0) return '시간 선택';
val = parseFloat(val);
if (val === Math.floor(val)) return val + '시간';
const hours = Math.floor(val);
const mins = Math.round((val - hours) * 60);
return hours > 0 ? hours + '시간 ' + mins + '분' : mins + '분';
if (window.getSSOUser) return window.getSSOUser();
const raw = localStorage.getItem('sso_user') || localStorage.getItem('user');
try { return raw ? JSON.parse(raw) : null; } catch(e) { return null; }
}
/**
@@ -278,7 +209,7 @@ function renderTbmWorkList() {
byDate[dateStr].sessions[sessionKey] = {
session_id: tbm.session_id,
session_date: tbm.session_date,
created_by_name: tbm.created_by_name,
created_by_name: tbm.leader_name || tbm.created_by_name || '-',
items: []
};
}
@@ -462,34 +393,29 @@ function renderTbmWorkList() {
}
return false;
});
// 근태 기반 자동 시간 채움
const defaultHours = tbm.attendance_type ? getDefaultHoursFromAttendance(tbm) : 0;
const hasDefaultHours = defaultHours > 0;
const attendanceBadgeHtml = tbm.attendance_type ? getAttendanceBadgeHtml(tbm.attendance_type) : '';
return `
<tr data-index="${index}" data-type="tbm" data-session-key="${key}">
<td>
<div class="worker-cell">
<strong>${tbm.worker_name || '작업자'}</strong>${attendanceBadgeHtml}
<strong>${tbm.worker_name || '작업자'}</strong>
<div class="worker-job-type">${tbm.job_type || '-'}</div>
</div>
</td>
<td>${tbm.project_name || '-'}</td>
<td>${tbm.work_type_name || '-'}</td>
<td>${tbm.task_name || '-'}</td>
<td>
<td data-label="프로젝트">${tbm.project_name || '-'}</td>
<td data-label="공정">${tbm.work_type_name || '-'}</td>
<td data-label="작업">${tbm.task_name || '-'}</td>
<td data-label="작업장소">
<div class="workplace-cell">
<div>${tbm.category_name || ''}</div>
<div>${tbm.workplace_name || '-'}</div>
</div>
</td>
<td>
<input type="hidden" id="totalHours_${index}" value="${hasDefaultHours ? defaultHours : ''}" required>
<div class="time-input-trigger ${hasDefaultHours ? '' : 'placeholder'}"
<input type="hidden" id="totalHours_${index}" value="" required>
<div class="time-input-trigger placeholder"
id="totalHoursDisplay_${index}"
onclick="openTimePicker(${index}, 'total')"
style="${hasDefaultHours ? 'color:#1f2937; font-weight:600;' : ''}">
${hasDefaultHours ? formatHoursDisplay(defaultHours) : '시간 선택'}
onclick="openTimePicker(${index}, 'total')">
시간 선택
</div>
</td>
<td>
@@ -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;