fix: TBM 관리 탭 데스크탑-모바일 데이터 불일치 해결

- TBM 관리 탭의 비관리자 클라이언트 필터링 제거 (모바일과 동일하게 전체 표시)
- tbm.js의 state.js 프록시 변수 중복 선언 제거 (mapRegions, mapCanvas 등)
- 누락된 common/utils.js, common/base-state.js 추가
- 캐시 버스팅을 위한 버전 쿼리 스트링 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-04 15:42:29 +09:00
parent e7272b0688
commit 22a37ac4d9
5 changed files with 537 additions and 2414 deletions

View File

@@ -0,0 +1,56 @@
/**
* BaseState - 상태 관리 베이스 클래스
* TbmState / DailyWorkReportState 공통 패턴
*/
class BaseState {
constructor() {
this.listeners = new Map();
}
/**
* 상태 업데이트 + 리스너 알림
*/
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(`[${this.constructor.name}] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 사용자 정보 (localStorage)
*/
getUser() {
const userInfo = localStorage.getItem('sso_user');
return userInfo ? JSON.parse(userInfo) : null;
}
}
// 전역 노출
window.BaseState = BaseState;
console.log('[Module] common/base-state.js 로드 완료');

View File

@@ -0,0 +1,144 @@
/**
* Common Utilities
* TBM/작업보고 공통 유틸리티 함수
*/
class CommonUtils {
/**
* 서울 시간대(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));
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}`;
}
/**
* 날짜를 YYYY-MM-DD 형식으로 변환 (문자열 또는 Date 객체)
*/
formatDate(date) {
if (!date) return '';
// 이미 YYYY-MM-DD 형식이면 그대로 반환
if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return date;
}
const dateObj = date instanceof Date ? date : new Date(date);
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}`;
}
/**
* 요일 반환 (일/월/화/수/목/금/토)
*/
getDayOfWeek(date) {
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dateObj = date instanceof Date ? date : new Date(date instanceof String || typeof date === 'string' ? date + 'T00:00:00' : date);
return dayNames[dateObj.getDay()];
}
/**
* 오늘인지 확인
*/
isToday(date) {
return this.formatDate(date) === this.getTodayKST();
}
/**
* UUID v4 생성
*/
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;
}
/**
* 디바운스
*/
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);
}
};
}
/**
* 객체 깊은 복사
*/
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;
}
/**
* 배열 그룹화
*/
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;
}, {});
}
}
// 전역 인스턴스 생성
window.CommonUtils = new CommonUtils();
console.log('[Module] common/utils.js 로드 완료');

View File

@@ -1,36 +1,15 @@
// tbm.js - TBM 관리 페이지 JavaScript
// 전역 변수: TbmState 프록시 사용 (state.js에서 정의)
// allSessions, todaySessions, allWorkers, allProjects, allWorkTypes, allTasks,
// allSafetyChecks, allWorkplaces, allWorkplaceCategories, currentUser,
// currentSessionId, selectedWorkers, workerTaskList, selectedWorkersInModal,
// currentEditingTaskLine, selectedCategory, selectedWorkplace, selectedCategoryName,
// selectedWorkplaceName, isBulkMode, bulkSelectedWorkers, loadedDaysCount,
// dateGroupedSessions, allLoadedSessions → window 프록시로 접근
// 전역 변수
let allSessions = [];
let todaySessions = [];
let allWorkers = [];
let allProjects = [];
let allWorkTypes = [];
let allTasks = [];
let allSafetyChecks = [];
let allWorkplaces = [];
let allWorkplaceCategories = [];
let currentUser = null;
let currentSessionId = null;
let selectedWorkers = new Set();
// UI 전용 변수 (프록시 없음)
let currentTab = 'tbm-input';
// 새로운 TBM 입력 방식 관련 변수
let workerTaskList = []; // [{worker_id, worker_name, job_type, tasks: [{task_line_id, project_id, ...}]}]
let selectedWorkersInModal = new Set(); // 모달에서 선택된 작업자 ID 세트
let currentEditingTaskLine = null; // 현재 편집 중인 작업 라인 정보 {workerIndex, taskIndex}
let selectedCategory = null;
let selectedWorkplace = null;
let selectedCategoryName = '';
let selectedWorkplaceName = '';
let isBulkMode = false; // 일괄 설정 모드인지 여부
let bulkSelectedWorkers = new Set(); // 일괄 설정에서 선택된 작업자 인덱스
// TBM 관리 탭용 변수
let loadedDaysCount = 7; // 처음에 로드할 일수
let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션
let allLoadedSessions = []; // 전체 로드된 세션
// 모달 스크롤 잠금
let scrollLockY = 0;
let scrollLockCount = 0;
@@ -56,46 +35,10 @@ function unlockBodyScroll() {
document.body.classList.remove('tbm-modal-open');
}
// ==================== 유틸리티 함수 ====================
/**
* 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환
*/
function getTodayKST() {
const now = new Date();
// 한국 시간대로 변환 (UTC+9)
const kstOffset = 9 * 60; // 9시간을 분 단위로
const utc = now.getTime() + (now.getTimezoneOffset() * 60000); // UTC 시간
const kstTime = new Date(utc + (kstOffset * 60000)); // KST 시간
const year = kstTime.getFullYear();
const month = String(kstTime.getMonth() + 1).padStart(2, '0');
const day = String(kstTime.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* ISO 날짜 문자열을 YYYY-MM-DD 형식으로 변환
* @param {string} dateString - ISO 형식 날짜 문자열 또는 YYYY-MM-DD 형식
* @returns {string} YYYY-MM-DD 형식 날짜
*/
function formatDate(dateString) {
if (!dateString) return '';
// 이미 YYYY-MM-DD 형식이면 그대로 반환
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return dateString;
}
// ISO 형식 또는 다른 형식이면 변환
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// ==================== 유틸리티 함수 (CommonUtils 위임) ====================
// getTodayKST, formatDate → window.CommonUtils 사용 (common/utils.js)
function getTodayKST() { return window.CommonUtils.getTodayKST(); }
function formatDate(d) { return window.CommonUtils.formatDate(d); }
// ==================== 페이지 초기화 ====================
@@ -135,72 +78,12 @@ function setupEventListeners() {
// 날짜 선택기 제거됨 - 날짜별 그룹 뷰 사용
}
// 초기 데이터 로드
// 초기 데이터 로드 → TbmAPI 위임
async function loadInitialData() {
try {
// 현재 로그인한 사용자 정보 가져오기
const userInfo = JSON.parse(localStorage.getItem('sso_user') || '{}');
currentUser = userInfo;
console.log('👤 로그인 사용자:', currentUser, 'worker_id:', currentUser?.worker_id);
// 작업자 목록 로드 (생산팀 소속만)
const workersResponse = await window.apiCall('/workers?limit=1000&department_id=1');
if (workersResponse) {
allWorkers = Array.isArray(workersResponse) ? workersResponse : (workersResponse.data || []);
// 활성 상태인 작업자만 필터링
allWorkers = allWorkers.filter(w => w.status === 'active' && w.employment_status === 'employed');
console.log('✅ 작업자 목록 로드:', allWorkers.length + '명');
}
// 프로젝트 목록 로드 (활성 프로젝트만)
const projectsResponse = await window.apiCall('/projects?is_active=1');
if (projectsResponse) {
const projects = Array.isArray(projectsResponse) ? projectsResponse : (projectsResponse.data || []);
// 활성 프로젝트만 필터링 (is_active가 1 또는 true인 경우)
allProjects = projects.filter(p => p.is_active === 1 || p.is_active === true || p.is_active === '1');
console.log('✅ 프로젝트 목록 로드:', allProjects.length + '개 (활성)');
populateProjectSelect();
}
// 안전 체크리스트 로드
const safetyResponse = await window.apiCall('/tbm/safety-checks');
if (safetyResponse && safetyResponse.success) {
allSafetyChecks = safetyResponse.data;
console.log('✅ 안전 체크리스트 로드:', allSafetyChecks.length + '개');
}
// 공정(Work Types) 목록 로드
const workTypesResponse = await window.apiCall('/daily-work-reports/work-types');
if (workTypesResponse && workTypesResponse.success) {
allWorkTypes = workTypesResponse.data || [];
console.log('✅ 공정 목록 로드:', allWorkTypes.length + '개');
}
// 작업(Tasks) 목록 로드
const tasksResponse = await window.apiCall('/tasks/active/list');
if (tasksResponse && tasksResponse.success) {
allTasks = tasksResponse.data || [];
console.log('✅ 작업 목록 로드:', allTasks.length + '개');
}
// 작업장 목록 로드
const workplacesResponse = await window.apiCall('/workplaces?is_active=true');
if (workplacesResponse && workplacesResponse.success) {
allWorkplaces = workplacesResponse.data || [];
console.log('✅ 작업장 목록 로드:', allWorkplaces.length + '개');
}
// 작업장 카테고리 로드
const categoriesResponse = await window.apiCall('/workplaces/categories/active/list');
if (categoriesResponse && categoriesResponse.success) {
allWorkplaceCategories = categoriesResponse.data || [];
console.log('✅ 작업장 카테고리 로드:', allWorkplaceCategories.length + '개');
}
} catch (error) {
console.error('❌ 초기 데이터 로드 오류:', error);
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
await window.TbmAPI.loadInitialData();
// TbmAPI가 TbmState에 데이터를 설정 → 프록시를 통해 전역 변수로 접근 가능
// UI 드롭다운 채우기
populateProjectSelect();
}
// ==================== 탭 전환 ====================
@@ -235,26 +118,10 @@ window.switchTbmTab = switchTbmTab;
// ==================== TBM 입력 탭 ====================
// 오늘의 TBM만 로드 (TBM 입력 탭용)
// 오늘의 TBM만 로드 → TbmAPI 위임
async function loadTodayOnlyTbm() {
const today = getTodayKST();
try {
const response = await window.apiCall(`/tbm/sessions/date/${today}`);
if (response && response.success) {
todaySessions = response.data || [];
displayTodayTbmSessions();
} else {
todaySessions = [];
displayTodayTbmSessions();
}
} catch (error) {
console.error('❌ 오늘 TBM 조회 오류:', error);
showToast('오늘 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
todaySessions = [];
displayTodayTbmSessions();
}
await window.TbmAPI.loadTodayOnlyTbm();
displayTodayTbmSessions();
}
window.loadTodayOnlyTbm = loadTodayOnlyTbm;
@@ -289,87 +156,20 @@ function displayTodayTbmSessions() {
// ==================== TBM 관리 탭 ====================
// 오늘 TBM 로드 (TBM 관리 탭용) - 레거시 호환
async function loadTodayTbm() {
await loadRecentTbmGroupedByDate();
}
window.loadTodayTbm = loadTodayTbm;
// 전체 TBM 로드 - 레거시 호환
async function loadAllTbm() {
loadedDaysCount = 30; // 30일치 로드
await loadRecentTbmGroupedByDate();
}
window.loadAllTbm = loadAllTbm;
// 레거시 호환 → api.js의 window alias 사용
// ==================== 날짜별 그룹 TBM 로드 (새 기능) ====================
/**
* 사용자가 Admin인지 확인
*/
function isAdminUser() {
if (!currentUser) return false;
return currentUser.role === 'Admin' || currentUser.role === 'System Admin';
}
function isAdminUser() { return window.TbmState.isAdminUser(); }
/**
* 최근 TBM을 날짜별로 그룹화하여 로드
* 최근 TBM을 날짜별로 그룹화하여 로드 → TbmAPI 위임
*/
async function loadRecentTbmGroupedByDate() {
try {
const today = new Date();
const dates = [];
// 최근 N일의 날짜 생성
for (let i = 0; i < loadedDaysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
dates.push(dateStr);
}
// 각 날짜의 TBM 로드
dateGroupedSessions = {};
allLoadedSessions = [];
const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`));
const results = await Promise.all(promises);
results.forEach((response, index) => {
const date = dates[index];
if (response && response.success && response.data && response.data.length > 0) {
let sessions = response.data;
// admin이 아니면 본인이 작성한 TBM만 필터링
if (!isAdminUser()) {
const userId = currentUser?.user_id;
const workerId = currentUser?.worker_id;
sessions = sessions.filter(s => {
return s.created_by === userId ||
s.leader_id === workerId ||
s.created_by_name === currentUser?.name;
});
}
if (sessions.length > 0) {
dateGroupedSessions[date] = sessions;
allLoadedSessions = allLoadedSessions.concat(sessions);
}
}
});
// 날짜별 그룹 표시
displayTbmGroupedByDate();
// 뷰 모드 표시
updateViewModeIndicator();
} catch (error) {
console.error('❌ TBM 날짜별 로드 오류:', error);
showToast('TBM을 불러오는 중 오류가 발생했습니다.', 'error');
dateGroupedSessions = {};
displayTbmGroupedByDate();
}
await window.TbmAPI.loadRecentTbmGroupedByDate();
// TbmState에 dateGroupedSessions, allLoadedSessions가 설정됨
displayTbmGroupedByDate();
updateViewModeIndicator();
}
window.loadRecentTbmGroupedByDate = loadRecentTbmGroupedByDate;
@@ -464,31 +264,12 @@ window.toggleDateGroup = toggleDateGroup;
/**
* 더 많은 날짜 로드
*/
async function loadMoreTbmDays() {
loadedDaysCount += 7; // 7일씩 추가
await loadRecentTbmGroupedByDate();
showToast(`최근 ${loadedDaysCount}일의 TBM을 로드했습니다.`, 'success');
}
window.loadMoreTbmDays = loadMoreTbmDays;
// loadMoreTbmDays → api.js의 window alias 사용
// 특정 날짜의 TBM 세션 목록 로드
// 특정 날짜의 TBM 세션 목록 로드 → TbmAPI 위임
async function loadTbmSessionsByDate(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
if (response && response.success) {
allSessions = response.data || [];
displayTbmSessions();
} else {
allSessions = [];
displayTbmSessions();
}
} catch (error) {
console.error('❌ TBM 세션 조회 오류:', error);
showToast('TBM 세션을 불러오는 중 오류가 발생했습니다.', 'error');
allSessions = [];
displayTbmSessions();
}
await window.TbmAPI.loadTbmSessionsByDate(date);
displayTbmSessions();
}
// TBM 세션 목록 표시 (관리 탭용) - 레거시 호환 (날짜별 그룹 뷰 사용)
@@ -659,15 +440,13 @@ async function renderNewTbmWorkerGrid() {
if (!todayAssignmentsMap) {
try {
const today = getTodayKST();
const res = await apiCall(`/tbm/sessions/date/${today}/assignments`);
const assignments = await window.TbmAPI.loadTodayAssignments(today);
todayAssignmentsMap = {};
if (res && res.success) {
res.data.forEach(a => {
if (a.sessions && a.sessions.length > 0) {
todayAssignmentsMap[a.worker_id] = a;
}
});
}
assignments.forEach(a => {
if (a.sessions && a.sessions.length > 0) {
todayAssignmentsMap[a.worker_id] = a;
}
});
} catch(e) {
console.error('배정 현황 로드 오류:', e);
todayAssignmentsMap = {};
@@ -914,12 +693,8 @@ async function saveTbmSession() {
}
try {
await window.apiCall(`/tbm/sessions/${editingSessionId}/team/clear`, 'DELETE');
const teamResponse = await window.apiCall(
`/tbm/sessions/${editingSessionId}/team/batch`,
'POST',
{ members }
);
await window.TbmAPI.clearTeamMembers(editingSessionId);
const teamResponse = await window.TbmAPI.addTeamMembers(editingSessionId, members);
if (teamResponse && teamResponse.success) {
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명)`, 'success');
@@ -969,17 +744,13 @@ async function saveTbmSession() {
});
try {
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
const response = await window.TbmAPI.createTbmSession(sessionData);
if (response && response.success) {
const createdSessionId = response.data.session_id;
console.log('✅ TBM 세션 생성 완료:', createdSessionId);
const teamResponse = await window.apiCall(
`/tbm/sessions/${createdSessionId}/team/batch`,
'POST',
{ members }
);
const teamResponse = await window.TbmAPI.addTeamMembers(createdSessionId, members);
if (teamResponse && teamResponse.success) {
showToast(`TBM이 생성되었습니다 (작업자 ${members.length}명)`, 'success');
@@ -1739,13 +1510,11 @@ async function loadWorkplacesByCategory(categoryId) {
if (!workplaceList) return;
try {
const response = await window.apiCall(`/workplaces?category_id=${categoryId}`);
if (!response || !response.success || !response.data || response.data.length === 0) {
const workplaces = await window.TbmAPI.loadWorkplacesByCategory(categoryId);
if (!workplaces || workplaces.length === 0) {
workplaceList.innerHTML = '<div style="color: #9ca3af; text-align: center; padding: 2rem;">등록된 작업장이 없습니다</div>';
return;
}
const workplaces = response.data;
workplaceList.innerHTML = workplaces.map(workplace => `
<button type="button"
onclick="selectWorkplace(${workplace.workplace_id}, '${workplace.workplace_name}')"
@@ -1841,10 +1610,7 @@ function toggleWorkplaceList() {
window.toggleWorkplaceList = toggleWorkplaceList;
// 작업장 지도 로드 및 렌더링
let mapRegions = []; // 현재 로드된 지도 영역들
let mapCanvas = null;
let mapCtx = null;
let mapImage = null;
// mapRegions, mapCanvas, mapCtx, mapImage → TbmState 프록시 사용 (state.js)
async function loadWorkplaceMap(categoryId, layoutImagePath) {
try {
@@ -1863,12 +1629,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath) {
console.log('🖼️ 이미지 로드 시도:', fullImageUrl);
// 지도 영역 데이터 로드
const regionsResponse = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`);
if (regionsResponse && regionsResponse.success) {
mapRegions = regionsResponse.data || [];
} else {
mapRegions = [];
}
mapRegions = await window.TbmAPI.loadMapRegions(categoryId);
// 이미지 로드
mapImage = new Image();
@@ -2190,23 +1951,19 @@ async function openTeamCompositionModal(sessionId) {
try {
// 세션 정보 로드
const sessionResponse = await window.apiCall(`/tbm/sessions/${sessionId}`);
if (!sessionResponse || !sessionResponse.success) {
const session = await window.TbmAPI.getSession(sessionId);
if (!session) {
showToast('TBM 정보를 불러올 수 없습니다.', 'error');
return;
}
const session = sessionResponse.data; // data는 이미 객체
// 팀원 정보 로드
const teamResponse = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
if (!teamResponse || !teamResponse.success) {
const teamMembers = await window.TbmAPI.getTeamMembers(sessionId);
if (!teamMembers) {
showToast('팀원 정보를 불러올 수 없습니다.', 'error');
return;
}
const teamMembers = teamResponse.data;
// workerTaskList 구성
workerTaskList = [];
const workerMap = new Map();
@@ -2341,11 +2098,7 @@ async function saveTeamComposition() {
}));
try {
const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/team/batch`,
'POST',
{ members }
);
const response = await window.TbmAPI.addTeamMembers(currentSessionId, members);
if (response && response.success) {
showToast(`${selectedWorkers.size}명의 팀원이 추가되었습니다.`, 'success');
@@ -2374,13 +2127,9 @@ async function openSafetyCheckModal(sessionId) {
try {
// 필터링된 체크리스트 조회 (기본 + 날씨 + 작업별)
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety-checks/filtered`);
const filteredData = await window.TbmAPI.getFilteredSafetyChecks(sessionId);
if (!response || !response.success) {
throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.');
}
const { basic, weather, task, weatherInfo } = response.data;
const { basic, weather, task, weatherInfo } = filteredData;
const categoryNames = {
'PPE': '개인 보호 장비',
@@ -2560,18 +2309,9 @@ async function saveSafetyChecklist() {
});
try {
const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/safety`,
'POST',
{ records }
);
if (response && response.success) {
showToast('안전 체크가 완료되었습니다.', 'success');
closeSafetyModal();
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
await window.TbmAPI.saveSafetyChecks(currentSessionId, records);
showToast('안전 체크가 완료되었습니다.', 'success');
closeSafetyModal();
} catch (error) {
console.error('❌ 안전 체크 저장 오류:', error);
showToast('안전 체크 저장 중 오류가 발생했습니다.', 'error');
@@ -2594,8 +2334,7 @@ async function openCompleteTbmModal(sessionId) {
// 팀원 조회 → 근태 선택 렌더링
try {
const teamRes = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
completeModalTeam = (teamRes && teamRes.data) ? teamRes.data : [];
completeModalTeam = await window.TbmAPI.getTeamMembers(sessionId);
renderCompleteAttendanceList();
} catch (e) {
console.error('팀원 조회 오류:', e);
@@ -2731,16 +2470,12 @@ window.completeTbmSession = completeTbmSession;
async function viewTbmSession(sessionId) {
try {
// 세션 정보, 팀 구성, 안전 체크 동시 조회
const [sessionRes, teamRes, safetyRes] = await Promise.all([
window.apiCall(`/tbm/sessions/${sessionId}`),
window.apiCall(`/tbm/sessions/${sessionId}/team`),
window.apiCall(`/tbm/sessions/${sessionId}/safety`)
const [session, team, safety] = await Promise.all([
window.TbmAPI.getSession(sessionId),
window.TbmAPI.getTeamMembers(sessionId),
window.TbmAPI.getSafetyChecks(sessionId)
]);
const session = sessionRes?.data;
const team = teamRes?.data || [];
const safety = safetyRes?.data || [];
if (!session) {
showToast('세션 정보를 불러올 수 없습니다.', 'error');
return;
@@ -2881,6 +2616,12 @@ async function viewTbmSession(sessionId) {
<button type="button" class="tbm-btn tbm-btn-primary" onclick="closeDetailModal(); openTeamCompositionModal(${safeId})">
수정
</button>
<button type="button" class="tbm-btn" style="background:#8b5cf6; color:white;" onclick="closeDetailModal(); openDesktopSplitModal(${safeId})">
분할
</button>
<button type="button" class="tbm-btn" style="background:#f59e0b; color:white;" onclick="closeDetailModal(); openDesktopPullModal(${safeId})">
빼오기
</button>
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
`;
} else {
@@ -2906,27 +2647,20 @@ function confirmDeleteTbm(sessionId) {
}
window.confirmDeleteTbm = confirmDeleteTbm;
// TBM 세션 삭제
// TBM 세션 삭제 → TbmAPI 위임
async function deleteTbmSession(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}`, 'DELETE');
if (response && response.success) {
showToast('TBM이 삭제되었습니다.', 'success');
closeDetailModal();
// 목록 새로고침
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadRecentTbmGroupedByDate();
}
await window.TbmAPI.deleteSession(sessionId);
showToast('TBM이 삭제되었습니다.', 'success');
closeDetailModal();
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
showToast(response?.message || 'TBM 삭제에 실패했습니다.', 'error');
await loadRecentTbmGroupedByDate();
}
} catch (error) {
console.error('❌ TBM 삭제 오류:', error);
showToast('TBM 삭제 중 오류가 발생했습니다.', 'error');
showToast(error?.message || 'TBM 삭제 중 오류가 발생했습니다.', 'error');
}
}
window.deleteTbmSession = deleteTbmSession;
@@ -2944,14 +2678,11 @@ async function openHandoverModal(sessionId) {
// 세션 정보와 팀 구성 조회
try {
const [sessionRes, teamRes] = await Promise.all([
window.apiCall(`/tbm/sessions/${sessionId}`),
window.apiCall(`/tbm/sessions/${sessionId}/team`)
const [session, team] = await Promise.all([
window.TbmAPI.getSession(sessionId),
window.TbmAPI.getTeamMembers(sessionId)
]);
const session = sessionRes?.data;
const team = teamRes?.data || [];
if (!session) {
showToast('세션 정보를 불러올 수 없습니다.', 'error');
return;
@@ -3041,8 +2772,8 @@ async function saveHandover() {
try {
// 세션 정보 조회 (from_leader_id 가져오기)
const sessionRes = await window.apiCall(`/tbm/sessions/${sessionId}`);
const fromLeaderId = sessionRes?.data?.leader_id;
const sessionData = await window.TbmAPI.getSession(sessionId);
const fromLeaderId = sessionData?.leader_id;
if (!fromLeaderId) {
showToast('세션 정보를 찾을 수 없습니다.', 'error');
@@ -3060,7 +2791,7 @@ async function saveHandover() {
worker_ids: workerIds
};
const response = await window.apiCall('/tbm/handovers', 'POST', handoverData);
const response = await window.TbmAPI.saveHandover(handoverData);
if (response && response.success) {
showToast('작업 인계가 요청되었습니다.', 'success');
@@ -3075,4 +2806,174 @@ async function saveHandover() {
}
window.saveHandover = saveHandover;
// ==================== 데스크탑 분할 기능 ====================
let splitModalSessionId = null;
let splitModalTeam = [];
async function openDesktopSplitModal(sessionId) {
splitModalSessionId = sessionId;
try {
splitModalTeam = await window.TbmAPI.getTeamMembers(sessionId);
if (splitModalTeam.length === 0) {
showToast('팀원이 없습니다.', 'error'); return;
}
const modal = document.getElementById('splitModal');
if (!modal) { showToast('분할 모달을 찾을 수 없습니다.', 'error'); return; }
const list = document.getElementById('splitMemberList');
list.innerHTML = splitModalTeam.map((m, i) => {
const hours = m.work_hours != null ? parseFloat(m.work_hours) : 8;
return `
<div style="display:flex; align-items:center; justify-content:space-between; padding:0.5rem; border:1px solid #e5e7eb; border-radius:0.375rem;">
<div>
<strong>${escapeHtml(m.worker_name)}</strong>
<span style="font-size:0.75rem; color:#6b7280;">(${hours}h)</span>
</div>
<div style="display:flex; gap:0.25rem; align-items:center;">
<input type="number" id="split_hours_${i}" step="0.5" min="0.5" max="${hours - 0.5}" placeholder="분할시간" style="width:80px; padding:0.25rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.8125rem;">
<button type="button" class="tbm-btn tbm-btn-primary" style="padding:0.25rem 0.5rem; font-size:0.75rem;" onclick="executeSplit(${i})">분할</button>
</div>
</div>`;
}).join('');
modal.style.display = 'flex';
lockBodyScroll();
} catch(e) {
console.error('분할 모달 오류:', e);
showToast('팀원 조회 오류', 'error');
}
}
window.openDesktopSplitModal = openDesktopSplitModal;
async function executeSplit(memberIdx) {
const m = splitModalTeam[memberIdx];
const currentHours = m.work_hours != null ? parseFloat(m.work_hours) : 8;
const splitHours = parseFloat(document.getElementById(`split_hours_${memberIdx}`).value);
if (!splitHours || splitHours <= 0 || splitHours >= currentHours) {
showToast(`올바른 시간 입력 (0 < 시간 < ${currentHours})`, 'error'); return;
}
try {
await window.TbmAPI.updateTeamMember(splitModalSessionId, {
worker_id: m.worker_id, project_id: m.project_id, work_type_id: m.work_type_id,
task_id: m.task_id, workplace_category_id: m.workplace_category_id, workplace_id: m.workplace_id,
work_detail: m.work_detail, is_present: true, work_hours: splitHours
});
await window.TbmAPI.splitAssignment(splitModalSessionId, {
worker_id: m.worker_id, work_hours: currentHours - splitHours,
project_id: m.project_id, work_type_id: m.work_type_id
});
showToast(`${escapeHtml(m.worker_name)} 분할 완료: ${splitHours}h + ${currentHours - splitHours}h`, 'success');
closeSplitModal();
if (currentTab === 'tbm-input') await loadTodayOnlyTbm(); else await loadRecentTbmGroupedByDate();
} catch(e) {
console.error('분할 오류:', e);
showToast('분할 처리 중 오류', 'error');
}
}
window.executeSplit = executeSplit;
function closeSplitModal() {
const modal = document.getElementById('splitModal');
if (modal) modal.style.display = 'none';
unlockBodyScroll();
}
window.closeSplitModal = closeSplitModal;
// ==================== 데스크탑 빼오기 기능 ====================
let pullModalSessionId = null;
async function openDesktopPullModal(targetSessionId) {
pullModalSessionId = targetSessionId;
try {
const todayStr = getTodayKST();
const sessions = await window.TbmAPI.fetchSessionsByDate(todayStr);
const otherSessions = sessions.filter(s => s.session_id !== targetSessionId && s.status === 'draft');
const modal = document.getElementById('pullModal');
if (!modal) { showToast('빼오기 모달을 찾을 수 없습니다.', 'error'); return; }
const list = document.getElementById('pullSessionList');
if (otherSessions.length === 0) {
list.innerHTML = '<div style="padding:1rem; text-align:center; color:#9ca3af;">빼올 수 있는 다른 TBM이 없습니다.</div>';
} else {
list.innerHTML = otherSessions.map(s => {
const leader = escapeHtml(s.leader_name || s.created_by_name || '미지정');
const count = parseInt(s.team_member_count) || 0;
return `
<div style="border:1px solid #e5e7eb; border-radius:0.375rem; margin-bottom:0.5rem;">
<div style="padding:0.5rem 0.75rem; cursor:pointer; display:flex; justify-content:space-between; align-items:center;" onclick="togglePullSessionMembers(${s.session_id}, this)">
<div><strong>${leader}</strong> <span style="font-size:0.75rem; color:#6b7280;">(${count}명)</span></div>
<span style="font-size:0.75rem; color:#6b7280;">▼</span>
</div>
<div id="pullMembers_${s.session_id}" style="display:none; padding:0.5rem; border-top:1px solid #f3f4f6;"></div>
</div>`;
}).join('');
}
modal.style.display = 'flex';
lockBodyScroll();
} catch(e) {
console.error('빼오기 모달 오류:', e);
showToast('빼오기 데이터 로드 오류', 'error');
}
}
window.openDesktopPullModal = openDesktopPullModal;
async function togglePullSessionMembers(sessionId, el) {
const container = document.getElementById(`pullMembers_${sessionId}`);
if (container.style.display !== 'none') {
container.style.display = 'none'; return;
}
try {
const members = await window.TbmAPI.getTeamMembers(sessionId);
container.innerHTML = members.map(m => {
const hours = m.work_hours != null ? parseFloat(m.work_hours) : 8;
return `
<div style="display:flex; align-items:center; justify-content:space-between; padding:0.375rem 0; border-bottom:1px solid #f9fafb;">
<span>${escapeHtml(m.worker_name)} <span style="font-size:0.75rem; color:#6b7280;">(${hours}h)</span></span>
<div style="display:flex; gap:0.25rem; align-items:center;">
<input type="number" id="pull_h_${sessionId}_${m.worker_id}" step="0.5" min="0.5" max="${hours}" value="${hours}" style="width:60px; padding:0.25rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.75rem;">
<button type="button" class="tbm-btn tbm-btn-primary" style="padding:0.25rem 0.5rem; font-size:0.75rem;" onclick="executePull(${sessionId}, ${m.worker_id}, '${escapeHtml(m.worker_name)}')">빼오기</button>
</div>
</div>`;
}).join('') || '<div style="color:#9ca3af; padding:0.25rem;">팀원 없음</div>';
container.style.display = 'block';
} catch(e) {
container.innerHTML = '<div style="color:#ef4444; padding:0.25rem;">로드 오류</div>';
container.style.display = 'block';
}
}
window.togglePullSessionMembers = togglePullSessionMembers;
async function executePull(sourceSessionId, workerId, workerName) {
const hoursInput = document.getElementById(`pull_h_${sourceSessionId}_${workerId}`);
const hours = parseFloat(hoursInput?.value);
if (!hours || hours <= 0) { showToast('시간을 입력하세요', 'error'); return; }
try {
const res = await window.TbmAPI.transfer({
transfer_type: 'pull',
worker_id: workerId,
source_session_id: sourceSessionId,
dest_session_id: pullModalSessionId,
hours: hours
});
showToast(`${workerName} ${hours}h 빼오기 완료` + (res.data?.warning ? ` (${res.data.warning})` : ''), 'success');
closePullModal();
if (currentTab === 'tbm-input') await loadTodayOnlyTbm(); else await loadRecentTbmGroupedByDate();
} catch(e) {
console.error('빼오기 오류:', e);
showToast(e.message || '빼오기 처리 오류', 'error');
}
}
window.executePull = executePull;
function closePullModal() {
const modal = document.getElementById('pullModal');
if (modal) modal.style.display = 'none';
unlockBodyScroll();
}
window.closePullModal = closePullModal;
// showToast → api-base.js 전역 사용

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
@@ -692,17 +692,55 @@
</div>
</div>
<!-- 분할 모달 -->
<div id="splitModal" class="tbm-modal-overlay" style="display:none;">
<div class="tbm-modal-container" style="max-width:500px;">
<div class="tbm-modal-header">
<h2>작업 분할</h2>
<button type="button" class="tbm-modal-close" onclick="closeSplitModal()">×</button>
</div>
<div class="tbm-modal-body">
<p style="font-size:0.8125rem; color:#6b7280; margin-bottom:0.75rem;">작업자의 배정 시간을 분할합니다.</p>
<div id="splitMemberList" style="display:flex; flex-direction:column; gap:0.5rem;"></div>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeSplitModal()">닫기</button>
</div>
</div>
</div>
<!-- 빼오기 모달 -->
<div id="pullModal" class="tbm-modal-overlay" style="display:none;">
<div class="tbm-modal-container" style="max-width:500px;">
<div class="tbm-modal-header">
<h2>빼오기</h2>
<button type="button" class="tbm-modal-close" onclick="closePullModal()">×</button>
</div>
<div class="tbm-modal-body">
<p style="font-size:0.8125rem; color:#6b7280; margin-bottom:0.75rem;">다른 반장의 TBM에서 작업자를 빼옵니다.</p>
<div id="pullSessionList"></div>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closePullModal()">닫기</button>
</div>
</div>
</div>
<!-- 토스트 알림 -->
<div class="toast-container" id="toastContainer"></div>
</div>
<!-- 공통 모듈 -->
<script src="/js/common/utils.js?v=1"></script>
<script src="/js/common/base-state.js?v=1"></script>
<!-- TBM 모듈 (리팩토링된 구조) -->
<script src="/js/tbm/state.js?v=1"></script>
<script src="/js/tbm/utils.js?v=1"></script>
<script src="/js/tbm/api.js?v=1"></script>
<script src="/js/tbm/state.js?v=2"></script>
<script src="/js/tbm/utils.js?v=2"></script>
<script src="/js/tbm/api.js?v=4"></script>
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/tbm.js?v=10"></script>
<script defer src="/js/tbm.js?v=13"></script>
<!-- 모바일 하단 네비게이션 -->
<div id="mobile-nav-container"></div>