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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user