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

@@ -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 `
<div class="tbm-date-group" data-date="${date}">
<div class="tbm-date-header ${isToday ? 'today' : ''}" onclick="toggleDateGroup('${date}')">
<span class="tbm-date-toggle">&#9660;</span>
<span class="tbm-date-title">${displayDate}</span>
<span class="tbm-date-day">${dayName}요일</span>
${isToday ? '<span class="tbm-today-badge">오늘</span>' : ''}
<span class="tbm-date-count">${sessions.length}건</span>
</div>
<div class="tbm-date-content">
<div class="tbm-date-grid">
${sessions.map(session => this.createSessionCard(session)).join('')}
</div>
</div>
</div>
`;
}).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 `
<div class="tbm-session-card" onclick="viewTbmSession(${session.session_id})">
<div class="tbm-card-header">
<div class="tbm-card-header-top">
<div>
<h3 class="tbm-card-leader">
${leaderName}
<span class="tbm-card-leader-role">${leaderRole}</span>
</h3>
</div>
${statusBadge}
</div>
<div class="tbm-card-date">
<span>&#128197;</span>
${this.utils.formatDate(session.session_date)} ${session.start_time ? '| ' + session.start_time : ''}
</div>
</div>
<div class="tbm-card-body">
<div class="tbm-card-info-grid">
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">프로젝트</span>
<span class="tbm-card-info-value">${session.project_name || '-'}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">공정</span>
<span class="tbm-card-info-value">${session.work_type_name || '-'}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">작업장</span>
<span class="tbm-card-info-value">${session.work_location || '-'}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">팀원</span>
<span class="tbm-card-info-value">${session.team_member_count || 0}명</span>
</div>
</div>
</div>
${session.status === 'draft' ? `
<div class="tbm-card-footer">
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})">
&#128101; 팀 구성
</button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})">
&#10003; 안전 체크
</button>
</div>
` : ''}
</div>
`;
}
/**
* 디버그
*/
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 로드 완료');

View File

@@ -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);
});
}
/**
* 상태 초기화
*/

View File

@@ -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;
}