feat: TBM 모바일 시스템 + 작업 분할/이동 + 권한 통합

TBM 시스템:
- 4단계 워크플로우 (draft→세부편집→완료→작업보고)
- 모바일 전용 TBM 페이지 (tbm-mobile.html) + 3단계 생성 위자드
- 작업자 작업 분할 (work_hours + split_seq)
- 작업자 이동 보내기/빼오기 (tbm_transfers 테이블)
- 생성 시 중복 배정 방지 (당일 배정 현황 조회)
- 데스크탑 TBM 페이지 세부편집 기능 추가

작업보고서:
- 모바일 전용 작업보고서 페이지 (report-create-mobile.html)
- TBM에서 사전 등록된 work_hours 자동 반영

권한 시스템:
- tkuser user_page_permissions 테이블과 system1 페이지 접근 연동
- pageAccessRoutes를 userRoutes보다 먼저 등록 (라우트 우선순위 수정)
- TKUSER_DEFAULT_ACCESS 폴백 추가 (개인→부서→기본값 3단계)
- 권한 캐시키 갱신 (userPageAccess_v2)

기타:
- app-init.js 캐시 버스팅 (v=5)
- iOS Safari touch-action: manipulation 적용
- KST 타임존 날짜 버그 수정 (toISOString UTC 이슈)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-25 07:46:21 +09:00
parent d36303101e
commit 7637be33f3
65 changed files with 9470 additions and 240 deletions

View File

@@ -26,7 +26,7 @@
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
localStorage.removeItem('userPageAccess');
localStorage.removeItem('userPageAccess_v2');
}
// ===== 페이지 권한 캐시 =====
@@ -36,7 +36,7 @@
if (!currentUser || !currentUser.user_id) return null;
// 캐시 확인
const cached = localStorage.getItem('userPageAccess');
const cached = localStorage.getItem('userPageAccess_v2_v2');
if (cached) {
try {
const cacheData = JSON.parse(cached);
@@ -44,7 +44,7 @@
return cacheData.pages;
}
} catch (e) {
localStorage.removeItem('userPageAccess');
localStorage.removeItem('userPageAccess_v2');
}
}
@@ -67,7 +67,7 @@
const data = await response.json();
const pages = data.data.pageAccess || [];
localStorage.setItem('userPageAccess', JSON.stringify({
localStorage.setItem('userPageAccess_v2', JSON.stringify({
pages: pages,
timestamp: Date.now()
}));
@@ -91,11 +91,19 @@
}
// ===== 현재 페이지 키 추출 =====
// 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유)
var PAGE_KEY_ALIASES = {
'work.tbm-create': 'work.tbm',
'work.tbm-mobile': 'work.tbm',
'work.report-create-mobile': 'work.report-create'
};
function getCurrentPageKey() {
const path = window.location.pathname;
if (!path.startsWith('/pages/')) return null;
const pagePath = path.substring(7).replace('.html', '');
return pagePath.replace(/\//g, '.');
const rawKey = pagePath.replace(/\//g, '.');
return PAGE_KEY_ALIASES[rawKey] || rawKey;
}
// ===== 컴포넌트 로더 =====

View File

@@ -26,6 +26,12 @@ function clearAuthData() {
* /pages/admin/accounts.html -> admin.accounts
* /pages/dashboard.html -> dashboard
*/
// 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유)
var PAGE_KEY_ALIASES = {
'work.tbm-create': 'work.tbm',
'work.tbm-mobile': 'work.tbm'
};
function getCurrentPageKey() {
const path = window.location.pathname;
@@ -41,9 +47,9 @@ function getCurrentPageKey() {
const withoutExt = pagePath.replace('.html', '');
// 슬래시를 점으로 변환
const pageKey = withoutExt.replace(/\//g, '.');
const rawKey = withoutExt.replace(/\//g, '.');
return pageKey;
return PAGE_KEY_ALIASES[rawKey] || rawKey;
}
/**

File diff suppressed because it is too large Load Diff

View File

@@ -213,6 +213,46 @@ function getUser() {
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 + '분';
}
/**
* TBM 작업 목록 렌더링 (날짜별 > 세션별 그룹화)
* - 날짜별로 접기/펼치기 가능
@@ -422,11 +462,15 @@ 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>
<strong>${tbm.worker_name || '작업자'}</strong>${attendanceBadgeHtml}
<div class="worker-job-type">${tbm.job_type || '-'}</div>
</div>
</td>
@@ -440,11 +484,12 @@ function renderTbmWorkList() {
</div>
</td>
<td>
<input type="hidden" id="totalHours_${index}" value="" required>
<div class="time-input-trigger placeholder"
<input type="hidden" id="totalHours_${index}" value="${hasDefaultHours ? defaultHours : ''}" required>
<div class="time-input-trigger ${hasDefaultHours ? '' : 'placeholder'}"
id="totalHoursDisplay_${index}"
onclick="openTimePicker(${index}, 'total')">
시간 선택
onclick="openTimePicker(${index}, 'total')"
style="${hasDefaultHours ? 'color:#1f2937; font-weight:600;' : ''}">
${hasDefaultHours ? formatHoursDisplay(defaultHours) : '시간 선택'}
</div>
</td>
<td>

View File

@@ -0,0 +1,443 @@
// mobile-dashboard.js - 모바일 대시보드 v2
// 공장별 카테고리 탭 → 작업장 리스트 → 작업장별 상태 요약
(function() {
'use strict';
if (window.innerWidth > 768) return;
var today = new Date().toISOString().slice(0, 10);
// ==================== 캐시 변수 ====================
var categories = [];
var allWorkplaces = [];
var tbmByWorkplace = {};
var visitorsByWorkplace = {};
var movedByWorkplace = {};
var issuesByWorkplace = {};
var workplacesByCategory = {};
// ==================== 유틸리티 ====================
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function waitForApi(timeout) {
timeout = timeout || 5000;
return new Promise(function(resolve, reject) {
if (window.apiCall) return resolve();
var elapsed = 0;
var interval = setInterval(function() {
elapsed += 50;
if (window.apiCall) { clearInterval(interval); resolve(); }
else if (elapsed >= timeout) { clearInterval(interval); reject(new Error('apiCall timeout')); }
}, 50);
});
}
// ==================== 데이터 그룹핑 ====================
function groupTbmByWorkplace(sessions) {
tbmByWorkplace = {};
if (!Array.isArray(sessions)) return;
sessions.forEach(function(s) {
var wpId = s.workplace_id;
if (!wpId) return;
if (!tbmByWorkplace[wpId]) {
tbmByWorkplace[wpId] = { taskCount: 0, totalWorkers: 0, sessions: [] };
}
tbmByWorkplace[wpId].taskCount++;
// team_member_count + leader 1
tbmByWorkplace[wpId].totalWorkers += (parseInt(s.team_member_count) || 0) + 1;
tbmByWorkplace[wpId].sessions.push(s);
});
}
function groupVisitorsByWorkplace(requests) {
visitorsByWorkplace = {};
if (!Array.isArray(requests)) return;
requests.forEach(function(r) {
// 오늘 날짜 + 승인된 건만
if (r.visit_date !== today) return;
if (r.status !== 'approved') return;
var wpId = r.workplace_id;
if (!wpId) return;
if (!visitorsByWorkplace[wpId]) {
visitorsByWorkplace[wpId] = { visitCount: 0, totalVisitors: 0, requests: [] };
}
visitorsByWorkplace[wpId].visitCount++;
visitorsByWorkplace[wpId].totalVisitors += parseInt(r.visitor_count) || 0;
visitorsByWorkplace[wpId].requests.push(r);
});
}
function groupMovedByWorkplace(items) {
movedByWorkplace = {};
if (!Array.isArray(items)) return;
items.forEach(function(eq) {
var wpId = eq.current_workplace_id;
if (!wpId) return;
if (!movedByWorkplace[wpId]) {
movedByWorkplace[wpId] = { movedCount: 0, items: [] };
}
movedByWorkplace[wpId].movedCount++;
movedByWorkplace[wpId].items.push(eq);
});
}
function groupIssuesByWorkplace(issues) {
issuesByWorkplace = {};
if (!Array.isArray(issues)) return;
var activeStatuses = ['reported', 'received', 'in_progress'];
issues.forEach(function(issue) {
var wpId = issue.workplace_id;
if (!wpId) return;
if (activeStatuses.indexOf(issue.status) === -1) return;
if (!issuesByWorkplace[wpId]) {
issuesByWorkplace[wpId] = { activeCount: 0, items: [] };
}
issuesByWorkplace[wpId].activeCount++;
issuesByWorkplace[wpId].items.push(issue);
});
}
function groupWorkplacesByCategory(workplaces) {
workplacesByCategory = {};
if (!Array.isArray(workplaces)) return;
workplaces.forEach(function(wp) {
var catId = wp.category_id;
if (!catId) return;
if (!workplacesByCategory[catId]) {
workplacesByCategory[catId] = [];
}
workplacesByCategory[catId].push(wp);
});
}
// ==================== 렌더링 ====================
function renderCategoryTabs() {
var container = document.getElementById('mCategoryTabs');
if (!container || !categories.length) return;
var html = '';
categories.forEach(function(cat, idx) {
html += '<button class="md-cat-tab' + (idx === 0 ? ' active' : '') +
'" data-id="' + cat.category_id + '">' +
escapeHtml(cat.category_name) + '</button>';
});
// 전체 탭
html += '<button class="md-cat-tab" data-id="all">전체</button>';
container.innerHTML = html;
// 이벤트 바인딩
var tabs = container.querySelectorAll('.md-cat-tab');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
var catId = tab.getAttribute('data-id');
selectCategory(catId);
});
});
// 첫 번째 카테고리 자동 선택
if (categories.length > 0) {
selectCategory(String(categories[0].category_id));
}
}
function selectCategory(categoryId) {
var workplaces;
if (categoryId === 'all') {
workplaces = allWorkplaces.filter(function(wp) { return wp.is_active !== false; });
} else {
workplaces = (workplacesByCategory[categoryId] || []).filter(function(wp) {
return wp.is_active !== false;
});
}
renderWorkplaceList(workplaces);
}
function renderWorkplaceList(workplaces) {
var container = document.getElementById('mWorkplaceList');
if (!container) return;
if (!workplaces || workplaces.length === 0) {
container.innerHTML = '<div class="md-wp-empty-all">등록된 작업장이 없습니다.</div>';
return;
}
var html = '';
workplaces.forEach(function(wp) {
var wpId = wp.workplace_id;
var tbm = tbmByWorkplace[wpId];
var visitors = visitorsByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var hasAny = tbm || visitors || moved || issues;
html += '<div class="md-wp-card" data-wp-id="' + wpId + '">';
// 헤더 (클릭 영역)
html += '<div class="md-wp-header">';
html += '<h3 class="md-wp-name">' + escapeHtml(wp.workplace_name);
if (hasAny) {
html += '<span class="md-wp-toggle">&#9660;</span>';
}
html += '</h3>';
if (!hasAny) {
html += '<p class="md-wp-no-activity">오늘 활동이 없습니다</p>';
} else {
html += '<div class="md-wp-stats">';
// TBM 작업
if (tbm) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#128736;</span>' +
'<span class="md-wp-stat-text">작업 ' + tbm.taskCount + '건 &middot; ' + tbm.totalWorkers + '명</span>' +
'</div>';
}
// 방문
if (visitors) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#128682;</span>' +
'<span class="md-wp-stat-text">방문 ' + visitors.visitCount + '건 &middot; ' + visitors.totalVisitors + '명</span>' +
'</div>';
}
// 신고 (미완료만)
if (issues && issues.activeCount > 0) {
html += '<div class="md-wp-stat-row md-wp-stat--warning">' +
'<span class="md-wp-stat-icon">&#9888;</span>' +
'<span class="md-wp-stat-text">신고 ' + issues.activeCount + '건</span>' +
'</div>';
}
// 이동설비
if (moved && moved.movedCount > 0) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#8596;</span>' +
'<span class="md-wp-stat-text">이동설비 ' + moved.movedCount + '건</span>' +
'</div>';
}
html += '</div>';
}
html += '</div>'; // .md-wp-header
// 상세 영역 (활동 있는 카드만)
if (hasAny) {
html += '<div class="md-wp-detail">' + renderCardDetail(wpId) + '</div>';
}
html += '</div>'; // .md-wp-card
});
container.innerHTML = html;
// 클릭 이벤트 바인딩
var cards = container.querySelectorAll('.md-wp-card[data-wp-id]');
cards.forEach(function(card) {
var wpId = card.getAttribute('data-wp-id');
var hasActivity = tbmByWorkplace[wpId] || visitorsByWorkplace[wpId] ||
movedByWorkplace[wpId] || issuesByWorkplace[wpId];
if (!hasActivity) return;
card.querySelector('.md-wp-header').addEventListener('click', function() {
toggleCard(wpId);
});
});
}
// ==================== 카드 확장/접기 ====================
function toggleCard(wpId) {
var allCards = document.querySelectorAll('.md-wp-card.expanded');
var targetCard = document.querySelector('.md-wp-card[data-wp-id="' + wpId + '"]');
if (!targetCard) return;
var isExpanded = targetCard.classList.contains('expanded');
// 다른 카드 모두 접기 (아코디언)
allCards.forEach(function(card) {
card.classList.remove('expanded');
});
// 토글
if (!isExpanded) {
targetCard.classList.add('expanded');
}
}
function renderCardDetail(wpId) {
var html = '';
var tbm = tbmByWorkplace[wpId];
var visitors = visitorsByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
// TBM 작업
if (tbm && tbm.sessions.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 작업</div>';
tbm.sessions.forEach(function(s) {
var taskName = s.task_name || '작업명 미지정';
var leaderName = s.leader_name || '미지정';
var memberCount = (parseInt(s.team_member_count) || 0) + 1;
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(taskName) + '</div>';
html += '<div class="md-wp-detail-sub">' + escapeHtml(leaderName) + ' &middot; ' + memberCount + '명</div>';
html += '</div>';
});
html += '</div>';
}
// 방문
if (visitors && visitors.requests.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 방문</div>';
visitors.requests.forEach(function(r) {
var company = r.visitor_company || '업체 미지정';
var count = parseInt(r.visitor_count) || 0;
var purpose = r.purpose_name || '';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(company) + ' &middot; ' + count + '명';
if (purpose) html += ' &middot; ' + escapeHtml(purpose);
html += '</div>';
html += '</div>';
});
html += '</div>';
}
// 신고
if (issues && issues.items.length > 0) {
var statusMap = { reported: '신고', received: '접수', in_progress: '처리중' };
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 신고</div>';
issues.items.forEach(function(issue) {
var category = issue.issue_category_name || '미분류';
var desc = issue.additional_description || '';
if (desc.length > 30) desc = desc.substring(0, 30) + '...';
var statusText = statusMap[issue.status] || issue.status;
var statusClass = 'md-wp-issue-status--' + (issue.status || 'reported');
var reporter = issue.reporter_name || '';
var icon = issue.status === 'in_progress' ? '&#128308;' : '&#9888;';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + icon + ' ' + escapeHtml(category);
if (desc) html += ' &middot; ' + escapeHtml(desc);
html += '</div>';
html += '<div class="md-wp-detail-sub"><span class="md-wp-issue-status ' + statusClass + '">' + statusText + '</span>';
if (reporter) html += ' &rarr; ' + escapeHtml(reporter);
html += '</div>';
html += '</div>';
});
html += '</div>';
}
// 이동설비
if (moved && moved.items.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 이동설비</div>';
moved.items.forEach(function(eq) {
var eqName = eq.equipment_name || '설비명 미지정';
var fromWp = eq.original_workplace_name || '?';
var toWp = eq.current_workplace_name || '?';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(eqName) + '</div>';
html += '<div class="md-wp-detail-sub">' + escapeHtml(fromWp) + ' &rarr; ' + escapeHtml(toWp) + '</div>';
html += '</div>';
});
html += '</div>';
}
return html;
}
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async function() {
try {
await waitForApi();
} catch (e) {
console.error('mobile-dashboard: apiCall not available');
return;
}
var view = document.getElementById('mobileDashboardView');
if (!view) return;
view.style.display = 'block';
// 날짜 표시
var now = new Date();
var days = ['일', '월', '화', '수', '목', '금', '토'];
var dateEl = document.getElementById('mDateValue');
if (dateEl) {
dateEl.textContent = now.getFullYear() + '.' +
String(now.getMonth() + 1).padStart(2, '0') + '.' +
String(now.getDate()).padStart(2, '0') + ' (' + days[now.getDay()] + ')';
}
// 로딩 표시
var listContainer = document.getElementById('mWorkplaceList');
if (listContainer) {
listContainer.innerHTML =
'<div class="md-skeleton"></div>' +
'<div class="md-skeleton" style="margin-top:8px;"></div>' +
'<div class="md-skeleton" style="margin-top:8px;"></div>';
}
// 데이터 병렬 로딩
var results = await Promise.allSettled([
window.apiCall('/workplaces/categories'),
window.apiCall('/tbm/sessions/date/' + today),
window.apiCall('/workplace-visits/requests?visit_date=' + today + '&status=approved'),
window.apiCall('/equipments/moved/list'),
window.apiCall('/work-issues?start_date=' + today + '&end_date=' + today),
window.apiCall('/workplaces')
]);
// 카테고리
if (results[0].status === 'fulfilled' && results[0].value && results[0].value.success) {
categories = results[0].value.data || [];
}
// TBM
if (results[1].status === 'fulfilled' && results[1].value && results[1].value.success) {
groupTbmByWorkplace(results[1].value.data || []);
}
// 방문
if (results[2].status === 'fulfilled' && results[2].value && results[2].value.success) {
groupVisitorsByWorkplace(results[2].value.data || []);
}
// 이동설비
if (results[3].status === 'fulfilled' && results[3].value && results[3].value.success) {
groupMovedByWorkplace(results[3].value.data || []);
}
// 신고
if (results[4].status === 'fulfilled' && results[4].value && results[4].value.success) {
groupIssuesByWorkplace(results[4].value.data || []);
}
// 작업장 전체 (카테고리별 그룹핑)
if (results[5].status === 'fulfilled' && results[5].value && results[5].value.success) {
allWorkplaces = results[5].value.data || [];
groupWorkplacesByCategory(allWorkplaces);
}
// 렌더링
renderCategoryTabs();
});
})();

View File

@@ -0,0 +1,573 @@
/**
* TBM 모바일 위자드 - tbm-create.js
* 3단계 위자드로 TBM 세션을 생성하는 모바일 전용 페이지 로직
* Step 1: 작업자 선택, Step 2: 프로젝트+공정 선택, Step 3: 확인
* (작업/작업장은 생성 후 세부 편집 단계에서 입력)
*/
(function() {
'use strict';
// ==================== 위자드 상태 ====================
const W = {
step: 1,
totalSteps: 3,
sessionDate: null,
leaderId: null,
leaderName: '',
workers: new Set(), // worker_id Set
workerNames: {}, // { worker_id: worker_name }
projectId: null,
projectName: '',
workTypeId: null,
workTypeName: '',
showAddWorkType: false,
todayAssignments: null // 당일 배정 현황 캐시
};
const esc = window.escapeHtml || function(s) { return s || ''; };
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async function() {
try {
// apiCall이 준비될 때까지 대기
await waitForApiCall();
// 초기 데이터 로드
await window.TbmAPI.loadInitialData();
// 기본 정보 자동 설정
W.sessionDate = window.TbmUtils.getTodayKST();
var user = window.TbmState.getUser();
if (user) {
if (user.worker_id) {
var worker = window.TbmState.allWorkers.find(function(w) { return w.worker_id === user.worker_id; });
if (worker) {
W.leaderId = worker.worker_id;
W.leaderName = worker.worker_name;
} else {
W.leaderName = user.name || '';
}
} else {
W.leaderName = user.name || '';
}
}
// 로딩 해제
document.getElementById('loadingOverlay').style.display = 'none';
// 첫 스텝 렌더링
renderStep(1);
updateIndicator();
updateNav();
} catch (error) {
console.error('초기화 오류:', error);
document.getElementById('loadingOverlay').style.display = 'none';
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
});
function waitForApiCall() {
return new Promise(function(resolve) {
if (typeof window.apiCall === 'function') {
resolve();
return;
}
var checks = 0;
var interval = setInterval(function() {
checks++;
if (typeof window.apiCall === 'function' || checks > 50) {
clearInterval(interval);
resolve();
}
}, 100);
});
}
// ==================== 네비게이션 ====================
window.nextStep = function() {
if (!validateStep(W.step)) return;
if (W.step < W.totalSteps) {
W.step++;
renderStep(W.step);
updateIndicator();
updateNav();
window.scrollTo(0, 0);
}
};
window.prevStep = function() {
if (W.step > 1) {
W.step--;
renderStep(W.step);
updateIndicator();
updateNav();
window.scrollTo(0, 0);
}
};
window.goBack = function() {
if (W.step > 1) {
window.prevStep();
} else {
window.location.href = '/pages/work/tbm-mobile.html';
}
};
function updateIndicator() {
var steps = document.querySelectorAll('#stepIndicator .step');
var lines = document.querySelectorAll('#stepIndicator .step-line');
steps.forEach(function(el, i) {
el.classList.remove('active', 'completed');
if (i + 1 === W.step) {
el.classList.add('active');
} else if (i + 1 < W.step) {
el.classList.add('completed');
}
});
lines.forEach(function(el, i) {
el.style.background = (i + 1 < W.step) ? '#10b981' : '#e5e7eb';
});
}
function updateNav() {
var prevBtn = document.getElementById('prevBtn');
var nextBtn = document.getElementById('nextBtn');
if (W.step === 1) {
prevBtn.style.visibility = 'hidden';
prevBtn.onclick = null;
} else {
prevBtn.style.visibility = 'visible';
prevBtn.onclick = window.prevStep;
}
if (W.step === W.totalSteps) {
nextBtn.className = 'nav-btn nav-btn-save';
nextBtn.innerHTML = '저장';
nextBtn.onclick = saveWizard;
} else {
nextBtn.className = 'nav-btn nav-btn-next';
nextBtn.innerHTML = '다음 &#8594;';
nextBtn.onclick = window.nextStep;
}
nextBtn.disabled = false;
}
// ==================== 유효성 검사 ====================
function validateStep(step) {
switch (step) {
case 1: // 작업자 선택
if (W.workers.size === 0) {
showToast('최소 1명의 작업자를 선택해주세요.', 'warning');
return false;
}
return true;
case 2: // 프로젝트 + 공정
if (!W.workTypeId) {
showToast('공정을 선택해주세요.', 'warning');
return false;
}
return true;
default:
return true;
}
}
// ==================== 스텝 렌더링 ====================
function renderStep(step) {
var container = document.getElementById('stepContainer');
switch (step) {
case 1: renderStepWorkers(container); break;
case 2: renderStepProjectAndWorkType(container); break;
case 3: renderStepConfirm(container); break;
}
}
// --- Step 1: 작업자 선택 ---
async function renderStepWorkers(container) {
var workers = window.TbmState.allWorkers;
// 당일 배정 현황 로드 (첫 로드 시)
if (!W.todayAssignments) {
try {
var today = window.TbmUtils.getTodayKST();
var res = await window.apiCall('/tbm/sessions/date/' + today + '/assignments');
if (res && res.success) {
W.todayAssignments = {};
res.data.forEach(function(a) {
if (a.sessions && a.sessions.length > 0) {
W.todayAssignments[a.worker_id] = a;
}
});
} else {
W.todayAssignments = {};
}
} catch(e) {
console.error('배정 현황 로드 오류:', e);
W.todayAssignments = {};
}
}
var workerCards = workers.map(function(w) {
var selected = W.workers.has(w.worker_id) ? ' selected' : '';
var assignment = W.todayAssignments[w.worker_id];
var assigned = assignment && assignment.total_hours >= 8;
var partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8;
var badgeHtml = '';
var disabledClass = '';
var onclick = 'toggleWorker(' + w.worker_id + ')';
if (assigned) {
// 종일 배정됨 - 선택 불가
var leaderNames = assignment.sessions.map(function(s) { return s.leader_name || ''; }).join(', ');
badgeHtml = '<div style="font-size:0.625rem; color:#ef4444; margin-top:0.125rem;">' + esc(leaderNames) + ' TBM (' + assignment.total_hours + 'h)</div>';
disabledClass = ' disabled';
onclick = '';
} else if (partiallyAssigned) {
var remaining = 8 - assignment.total_hours;
badgeHtml = '<div style="font-size:0.625rem; color:#2563eb; margin-top:0.125rem;">' + remaining + 'h 가용</div>';
}
return '<div class="worker-card' + selected + disabledClass + '"' +
(onclick ? ' onclick="' + onclick + '"' : '') +
' data-wid="' + w.worker_id + '"' +
' style="' + (assigned ? 'opacity:0.5; pointer-events:none;' : '') + '">' +
'<div class="worker-check">&#10003;</div>' +
'<div class="worker-info">' +
'<div class="worker-name">' + esc(w.worker_name) + '</div>' +
'<div class="worker-type">' + esc(w.job_type || '작업자') + '</div>' +
badgeHtml +
'</div>' +
'</div>';
}).join('');
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">1</span>작업자 선택</div>' +
'<div class="select-all-bar">' +
'<span class="count" id="workerCount">' + W.workers.size + '명 선택</span>' +
'<button type="button" class="select-all-btn" onclick="toggleAllWorkers()">' +
(W.workers.size === workers.length ? '전체 해제' : '전체 선택') +
'</button>' +
'</div>' +
'<div class="worker-grid">' + workerCards + '</div>' +
'</div>';
}
window.toggleWorker = function(workerId) {
// 이미 종일 배정된 작업자는 선택 불가
var a = W.todayAssignments && W.todayAssignments[workerId];
if (a && a.total_hours >= 8) return;
if (W.workers.has(workerId)) {
W.workers.delete(workerId);
delete W.workerNames[workerId];
} else {
W.workers.add(workerId);
var w = window.TbmState.allWorkers.find(function(x) { return x.worker_id === workerId; });
if (w) W.workerNames[workerId] = w.worker_name;
}
var card = document.querySelector('[data-wid="' + workerId + '"]');
if (card) card.classList.toggle('selected');
var countEl = document.getElementById('workerCount');
if (countEl) countEl.textContent = W.workers.size + '명 선택';
};
window.toggleAllWorkers = function() {
var workers = window.TbmState.allWorkers;
var availableWorkers = workers.filter(function(w) {
var a = W.todayAssignments && W.todayAssignments[w.worker_id];
return !(a && a.total_hours >= 8);
});
if (W.workers.size === availableWorkers.length) {
W.workers.clear();
W.workerNames = {};
} else {
availableWorkers.forEach(function(w) {
W.workers.add(w.worker_id);
W.workerNames[w.worker_id] = w.worker_name;
});
}
renderStepWorkers(document.getElementById('stepContainer'));
};
// --- Step 2: 프로젝트 + 공정 선택 (통합) ---
function renderStepProjectAndWorkType(container) {
var projects = window.TbmState.allProjects;
var workTypes = window.TbmState.allWorkTypes;
// 프로젝트 선택 UI
var skipSelected = W.projectId === null ? ' selected' : '';
var projectItems = projects.map(function(p) {
var selected = W.projectId === p.project_id ? ' selected' : '';
return '<div class="list-item' + selected + '" onclick="selectProject(' + p.project_id + ', \'' + esc(p.project_name).replace(/'/g, "\\'") + '\')">' +
'<div class="item-title">' + esc(p.project_name) + '</div>' +
'<div class="item-desc">' + esc(p.job_no || '') + '</div>' +
'</div>';
}).join('');
// 공정 pill 버튼
var pillHtml = workTypes.map(function(wt) {
var selected = W.workTypeId === wt.id ? ' selected' : '';
return '<button type="button" class="pill-btn' + selected + '" onclick="selectWorkType(' + wt.id + ', \'' + esc(wt.name).replace(/'/g, "\\'") + '\')">' + esc(wt.name) + '</button>';
}).join('');
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
// 공정 인라인 추가 폼
var addWorkTypeFormHtml = '';
if (W.showAddWorkType) {
addWorkTypeFormHtml =
'<div class="inline-add-form" id="addWorkTypeForm">' +
'<input type="text" id="newWorkTypeName" placeholder="새 공정명 입력" autocomplete="off">' +
'<div class="inline-add-btns">' +
'<button type="button" class="btn-cancel" onclick="cancelAddWorkType()">취소</button>' +
'<button type="button" class="btn-save" id="btnSaveWorkType" onclick="saveNewWorkType()">저장</button>' +
'</div>' +
'</div>';
}
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">2</span>프로젝트 선택 <span style="font-size:0.75rem;font-weight:400;color:#9ca3af;">(선택사항)</span></div>' +
'<div class="list-item-skip' + skipSelected + '" onclick="selectProject(null, \'\')">' +
'선택 안함' +
'</div>' +
(projects.length > 0 ? projectItems : '<div class="empty-state">등록된 프로젝트가 없습니다</div>') +
'</div>' +
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">2</span>공정 선택 <span style="font-size:0.75rem;font-weight:400;color:#ef4444;">(필수)</span></div>' +
'<div class="pill-grid">' + pillHtml + '</div>' +
addWorkTypeFormHtml +
'</div>';
// 자동 포커스
if (W.showAddWorkType) {
var inp = document.getElementById('newWorkTypeName');
if (inp) {
setTimeout(function() { inp.focus(); }, 50);
inp.onkeydown = function(e) {
if (e.key === 'Enter') { e.preventDefault(); saveNewWorkType(); }
if (e.key === 'Escape') { cancelAddWorkType(); }
};
}
}
}
window.selectProject = function(projectId, projectName) {
W.projectId = projectId;
W.projectName = projectName || '';
// Update project list items
document.querySelectorAll('#stepContainer .list-item, #stepContainer .list-item-skip').forEach(function(el) {
el.classList.remove('selected');
});
if (projectId === null) {
var skipEl = document.querySelector('#stepContainer .list-item-skip');
if (skipEl) skipEl.classList.add('selected');
} else {
document.querySelectorAll('#stepContainer .list-item').forEach(function(el) {
var title = el.querySelector('.item-title');
if (title && title.textContent === projectName) {
el.classList.add('selected');
}
});
}
};
window.selectWorkType = function(id, name) {
W.workTypeId = id;
W.workTypeName = name;
// Update pill buttons
document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) {
el.classList.remove('selected');
});
document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) {
if (el.textContent === name) {
el.classList.add('selected');
}
});
};
// --- Step 2: 인라인 추가 (공정) ---
window.toggleAddWorkType = function() {
W.showAddWorkType = !W.showAddWorkType;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
};
window.cancelAddWorkType = function() {
W.showAddWorkType = false;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
};
window.saveNewWorkType = async function() {
var inp = document.getElementById('newWorkTypeName');
var btn = document.getElementById('btnSaveWorkType');
if (!inp || !btn) return;
var name = inp.value.trim();
if (!name) {
showToast('공정명을 입력해주세요.', 'warning');
inp.focus();
return;
}
var exists = window.TbmState.allWorkTypes.some(function(wt) {
return wt.name.toLowerCase() === name.toLowerCase();
});
if (exists) {
showToast('이미 존재하는 공정명입니다.', 'warning');
inp.focus();
return;
}
btn.disabled = true;
btn.textContent = '저장 중...';
try {
var response = await window.apiCall('/daily-work-reports/work-types', 'POST', { name: name });
if (!response || !response.success) {
throw new Error(response?.message || '공정 추가 실패');
}
var newItem = response.data;
window.TbmState.allWorkTypes.push(newItem);
W.workTypeId = newItem.id;
W.workTypeName = newItem.name;
W.showAddWorkType = false;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
showToast('\'' + name + '\' 공정이 추가되었습니다.', 'success');
} catch (error) {
console.error('공정 추가 오류:', error);
showToast('공정 추가 중 오류: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = '저장';
}
};
// --- Step 3: 확인 ---
function renderStepConfirm(container) {
var dateDisplay = window.TbmUtils.formatDateFull(W.sessionDate);
// 작업자 이름 목록
var workerNameList = [];
W.workers.forEach(function(wid) {
workerNameList.push(W.workerNames[wid] || '작업자');
});
var summaryHtml =
'<div class="summary-card">' +
'<div class="summary-row"><span class="summary-label">날짜</span><span class="summary-value">' + esc(dateDisplay) + '</span></div>' +
'<div class="summary-row"><span class="summary-label">입력자</span><span class="summary-value">' + esc(W.leaderName || '(미설정)') + '</span></div>' +
'<div class="summary-row"><span class="summary-label">프로젝트</span><span class="summary-value">' + esc(W.projectName || '선택 안함') + '</span></div>' +
'<div class="summary-row"><span class="summary-label">공정</span><span class="summary-value">' + esc(W.workTypeName) + '</span></div>' +
'<div class="summary-row"><span class="summary-label">작업자</span><span class="summary-value">' + W.workers.size + '명</span></div>' +
'</div>';
// 작업자 목록 (간단 표시)
var workerListHtml = workerNameList.map(function(name) {
return '<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;background:#f9fafb;border-radius:0.5rem;margin-bottom:0.25rem;">' +
'<span style="font-size:0.875rem;font-weight:500;color:#1f2937;">' + esc(name) + '</span>' +
'<span style="font-size:0.6875rem;color:#9ca3af;margin-left:auto;">세부 미입력</span>' +
'</div>';
}).join('');
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">3</span>확인</div>' +
summaryHtml +
'</div>' +
'<div class="wizard-section">' +
'<div class="section-title">작업자 목록</div>' +
'<div style="padding:0.5rem;background:#fff7ed;border:1px solid #fed7aa;border-radius:0.5rem;margin-bottom:0.75rem;font-size:0.8125rem;color:#c2410c;">' +
'저장 후 TBM 카드를 탭하면 작업자별 작업/작업장을 입력할 수 있습니다.' +
'</div>' +
workerListHtml +
'</div>';
}
// ==================== 저장 ====================
async function saveWizard() {
// 저장 버튼 비활성화
var saveBtn = document.getElementById('nextBtn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = '저장 중...';
}
try {
var leaderId = W.leaderId ? parseInt(W.leaderId) : null;
// 1. TBM 세션 생성
var sessionData = {
session_date: W.sessionDate,
leader_id: leaderId
};
var response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (!response || !response.success) {
throw new Error(response?.message || '세션 생성 실패');
}
var sessionId = response.data.session_id;
// 2. 팀원 일괄 추가 (task_id, workplace_id = null)
var members = [];
W.workers.forEach(function(wid) {
members.push({
worker_id: wid,
project_id: W.projectId,
work_type_id: W.workTypeId,
task_id: null,
workplace_category_id: null,
workplace_id: null,
work_detail: null,
is_present: true
});
});
var teamResponse = await window.apiCall(
'/tbm/sessions/' + sessionId + '/team/batch',
'POST',
{ members: members }
);
if (!teamResponse || !teamResponse.success) {
throw new Error(teamResponse?.message || '팀원 추가 실패');
}
showToast('TBM이 생성되었습니다 (작업자 ' + members.length + '명)', 'success');
// 3. tbm-mobile.html로 이동
setTimeout(function() {
window.location.href = '/pages/work/tbm-mobile.html';
}, 1000);
} catch (error) {
console.error('TBM 저장 오류:', error);
showToast('TBM 저장 중 오류가 발생했습니다: ' + error.message, 'error');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = '저장';
}
}
}
// ==================== 토스트 (로컬) ====================
function showToast(message, type) {
if (window.showToast && typeof window.showToast === 'function') {
window.showToast(message, type);
return;
}
console.log('[Toast] ' + type + ': ' + message);
}
})();

View File

@@ -517,15 +517,19 @@ function createSessionCard(session) {
'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
}[session.status] || '';
// 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시)
const leaderName = escapeHtml(session.leader_name || session.created_by_name || '작업 책임자');
const leaderRole = escapeHtml(session.leader_name
? (session.leader_job_type || '작업자')
: '관리자');
const safeSessionId = parseInt(session.session_id) || 0;
// 카드 클릭 동작: draft → 세부 편집, completed → 상세 보기
const onClickAction = session.status === 'draft'
? `openTeamCompositionModal(${safeSessionId})`
: `viewTbmSession(${safeSessionId})`;
return `
<div class="tbm-session-card" onclick="viewTbmSession(${safeSessionId})">
<div class="tbm-session-card" onclick="${onClickAction}">
<div class="tbm-card-header">
<div class="tbm-card-header-top">
<div>
@@ -558,7 +562,7 @@ function createSessionCard(session) {
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">팀원</span>
<span class="tbm-card-info-value">${parseInt(session.team_member_count) || 0}</span>
<span class="tbm-card-info-value">${escapeHtml(session.team_member_names || '')}${session.team_member_names ? '' : '없음'}</span>
</div>
</div>
</div>
@@ -566,7 +570,7 @@ function createSessionCard(session) {
${session.status === 'draft' ? `
<div class="tbm-card-footer">
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
&#128101; 수정
&#128101; 세부 편집
</button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
&#10003; 안전 체크
@@ -580,10 +584,16 @@ function createSessionCard(session) {
`;
}
// 새 TBM 모달 열기
// 새 TBM 모달 열기 (간소화: 프로젝트+공정+작업자만)
function openNewTbmModal() {
if (window.innerWidth <= 768) {
window.location.href = '/pages/work/tbm-create.html';
return;
}
currentSessionId = null;
workerTaskList = []; // 작업자 목록 초기화
workerTaskList = [];
selectedWorkersForNewTbm = new Set();
todayAssignmentsMap = null; // 배정 현황 캐시 초기화
document.getElementById('modalTitle').innerHTML = '<span>&#128221;</span> 새 TBM 시작';
document.getElementById('sessionId').value = '';
@@ -610,19 +620,140 @@ function openNewTbmModal() {
document.getElementById('leaderId').value = worker.worker_id;
}
} else if (currentUser && currentUser.name) {
// 관리자: 이름만 표시
document.getElementById('leaderName').textContent = currentUser.name;
document.getElementById('leaderId').value = '';
}
// 작업자 목록 UI 초기화
renderWorkerTaskList();
// 프로젝트 드롭다운 채우기
const projSelect = document.getElementById('newTbmProjectId');
if (projSelect) {
projSelect.innerHTML = '<option value="">선택 안함</option>' +
allProjects.map(p => `<option value="${p.project_id}">${escapeHtml(p.project_name)} (${escapeHtml(p.job_no || '')})</option>`).join('');
}
// 공정 드롭다운 채우기
const wtSelect = document.getElementById('newTbmWorkTypeId');
if (wtSelect) {
wtSelect.innerHTML = '<option value="">공정 선택...</option>' +
allWorkTypes.map(wt => `<option value="${wt.id}">${escapeHtml(wt.name)}</option>`).join('');
}
// 작업자 체크박스 그리드 렌더링
renderNewTbmWorkerGrid();
document.getElementById('tbmModal').style.display = 'flex';
lockBodyScroll();
}
window.openNewTbmModal = openNewTbmModal;
// 새 TBM 모달용 작업자 선택 세트
let selectedWorkersForNewTbm = new Set();
let todayAssignmentsMap = null; // 당일 배정 현황
// 작업자 그리드 렌더링
async function renderNewTbmWorkerGrid() {
const grid = document.getElementById('newTbmWorkerGrid');
if (!grid) return;
// 당일 배정 현황 로드
if (!todayAssignmentsMap) {
try {
const today = getTodayKST();
const res = await apiCall(`/tbm/sessions/date/${today}/assignments`);
todayAssignmentsMap = {};
if (res && res.success) {
res.data.forEach(a => {
if (a.sessions && a.sessions.length > 0) {
todayAssignmentsMap[a.worker_id] = a;
}
});
}
} catch(e) {
console.error('배정 현황 로드 오류:', e);
todayAssignmentsMap = {};
}
}
grid.innerHTML = allWorkers.map(w => {
const checked = selectedWorkersForNewTbm.has(w.worker_id) ? 'checked' : '';
const assignment = todayAssignmentsMap[w.worker_id];
const fullyAssigned = assignment && assignment.total_hours >= 8;
const partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8;
let badgeHtml = '';
let disabledAttr = '';
let disabledStyle = '';
if (fullyAssigned) {
const leaderNames = assignment.sessions.map(s => s.leader_name || '').join(', ');
badgeHtml = `<span style="font-size:0.625rem; color:#ef4444; display:block;">${escapeHtml(leaderNames)} TBM (${assignment.total_hours}h)</span>`;
disabledAttr = 'disabled';
disabledStyle = 'opacity:0.5; pointer-events:none;';
} else if (partiallyAssigned) {
const remaining = 8 - assignment.total_hours;
badgeHtml = `<span style="font-size:0.625rem; color:#2563eb; display:block;">${remaining}h 가용</span>`;
}
return `
<label class="tbm-worker-select-item ${checked ? 'selected' : ''}" data-wid="${w.worker_id}" style="${disabledStyle}">
<input type="checkbox" class="new-tbm-worker-cb" data-worker-id="${w.worker_id}" ${checked} ${disabledAttr}
onchange="toggleNewTbmWorker(${w.worker_id}, this.checked)">
<span class="tbm-worker-name">${escapeHtml(w.worker_name)}</span>
<span class="tbm-worker-role">${escapeHtml(w.job_type || '작업자')}</span>
${badgeHtml}
</label>
`;
}).join('');
updateNewTbmWorkerCount();
}
function updateNewTbmWorkerCount() {
const countEl = document.getElementById('newTbmWorkerCount');
if (countEl) countEl.textContent = `(${selectedWorkersForNewTbm.size}명)`;
}
function toggleNewTbmWorker(workerId, checked) {
// 종일 배정된 작업자 선택 방지
const a = todayAssignmentsMap && todayAssignmentsMap[workerId];
if (a && a.total_hours >= 8) return;
if (checked) {
selectedWorkersForNewTbm.add(workerId);
} else {
selectedWorkersForNewTbm.delete(workerId);
}
// Update visual state
const label = document.querySelector(`#newTbmWorkerGrid label[data-wid="${workerId}"]`);
if (label) label.classList.toggle('selected', checked);
updateNewTbmWorkerCount();
}
window.toggleNewTbmWorker = toggleNewTbmWorker;
function selectAllNewTbmWorkers() {
allWorkers.forEach(w => {
const a = todayAssignmentsMap && todayAssignmentsMap[w.worker_id];
if (a && a.total_hours >= 8) return; // 종일 배정 제외
selectedWorkersForNewTbm.add(w.worker_id);
});
document.querySelectorAll('.new-tbm-worker-cb').forEach(cb => {
if (!cb.disabled) cb.checked = true;
});
document.querySelectorAll('#newTbmWorkerGrid label').forEach(l => {
if (l.style.opacity !== '0.5') l.classList.add('selected');
});
updateNewTbmWorkerCount();
}
window.selectAllNewTbmWorkers = selectAllNewTbmWorkers;
function deselectAllNewTbmWorkers() {
selectedWorkersForNewTbm.clear();
document.querySelectorAll('.new-tbm-worker-cb').forEach(cb => { cb.checked = false; });
document.querySelectorAll('#newTbmWorkerGrid label').forEach(l => l.classList.remove('selected'));
updateNewTbmWorkerCount();
}
window.deselectAllNewTbmWorkers = deselectAllNewTbmWorkers;
// 입력자 선택 드롭다운 채우기
function populateLeaderSelect() {
const leaderSelect = document.getElementById('leaderId');
@@ -729,13 +860,12 @@ function closeTbmModal() {
}
window.closeTbmModal = closeTbmModal;
// TBM 세션 저장 (작업자별 상세 정보 포함)
// TBM 세션 저장 (간소화: 프로젝트+공정+작업자, task/workplace=null)
async function saveTbmSession() {
console.log('💾 TBM 저장 시작...');
let leaderId = parseInt(document.getElementById('leaderId').value);
// 관리자 계정인 경우 leader_id를 null로 설정
if (!leaderId || isNaN(leaderId)) {
if (!currentUser.worker_id) {
console.log('📝 관리자 계정: leader_id를 NULL로 설정');
@@ -752,66 +882,39 @@ async function saveTbmSession() {
leader_id: leaderId
};
console.log('📅 세션 데이터:', sessionData);
console.log('👥 작업자 리스트:', workerTaskList);
console.log('👤 현재 사용자:', currentUser);
if (!sessionData.session_date) {
console.error('❌ 날짜 누락');
showToast('TBM 날짜를 확인해주세요.', 'error');
return;
}
if (workerTaskList.length === 0) {
console.error('❌ 작업자 리스트가 비어있음');
showToast('최소 1명 이상의 작업자를 추가해주세요.', 'error');
return;
}
const editingSessionId = document.getElementById('sessionId').value;
// 필수 항목 검증 (공정, 작업, 작업장)
let hasError = false;
for (const workerData of workerTaskList) {
for (const taskLine of workerData.tasks) {
if (!taskLine.work_type_id || !taskLine.task_id || !taskLine.workplace_id) {
showToast(`${workerData.worker_name}의 공정, 작업, 작업장을 모두 선택해주세요.`, 'error');
hasError = true;
break;
// 수정 모드일 때는 기존 openTeamCompositionModal의 workerTaskList를 사용
if (editingSessionId) {
// 기존 수정 모드 로직 (openTeamCompositionModal 경유)
if (workerTaskList.length === 0) {
showToast('최소 1명 이상의 작업자를 추가해주세요.', 'error');
return;
}
const members = [];
for (const workerData of workerTaskList) {
for (const taskLine of workerData.tasks) {
members.push({
worker_id: workerData.worker_id,
project_id: taskLine.project_id || null,
work_type_id: taskLine.work_type_id,
task_id: taskLine.task_id,
workplace_category_id: taskLine.workplace_category_id || null,
workplace_id: taskLine.workplace_id,
work_detail: taskLine.work_detail || null,
is_present: taskLine.is_present !== undefined ? taskLine.is_present : true
});
}
}
if (hasError) break;
}
if (hasError) return;
// 작업자-작업 데이터를 평평하게 변환
const members = [];
for (const workerData of workerTaskList) {
for (const taskLine of workerData.tasks) {
members.push({
worker_id: workerData.worker_id,
project_id: taskLine.project_id || null,
work_type_id: taskLine.work_type_id,
task_id: taskLine.task_id,
workplace_category_id: taskLine.workplace_category_id || null,
workplace_id: taskLine.workplace_id,
work_detail: taskLine.work_detail || null,
is_present: taskLine.is_present !== undefined ? taskLine.is_present : true
});
}
}
console.log('📤 전송할 팀 데이터:', members);
try {
const editingSessionId = document.getElementById('sessionId').value;
if (editingSessionId) {
// 수정 모드: 기존 팀원 삭제 후 재등록
console.log('📝 TBM 수정 모드:', editingSessionId);
// 기존 팀원 삭제
try {
await window.apiCall(`/tbm/sessions/${editingSessionId}/team/clear`, 'DELETE');
// 새 팀원 일괄 추가
const teamResponse = await window.apiCall(
`/tbm/sessions/${editingSessionId}/team/batch`,
'POST',
@@ -819,51 +922,79 @@ async function saveTbmSession() {
);
if (teamResponse && teamResponse.success) {
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}, 작업 ${members.length})`, 'success');
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명)`, 'success');
closeTbmModal();
// 목록 새로고침
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadTbmSessionsByDate(sessionData.session_date);
await loadRecentTbmGroupedByDate();
}
} else {
throw new Error(teamResponse.message || '팀원 수정에 실패했습니다.');
}
} else {
// 생성 모드: 새 TBM 세션 생성
console.log('TBM 생성 모드');
} catch (error) {
console.error('❌ TBM 세션 수정 오류:', error);
showToast('TBM 세션 수정 중 오류가 발생했습니다.', 'error');
}
return;
}
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
// 생성 모드: 간소화된 새 TBM
const workTypeId = parseInt(document.getElementById('newTbmWorkTypeId')?.value);
const projectId = parseInt(document.getElementById('newTbmProjectId')?.value) || null;
if (response && response.success) {
const createdSessionId = response.data.session_id;
console.log('✅ TBM 세션 생성 완료:', createdSessionId);
if (!workTypeId) {
showToast('공정을 선택해주세요.', 'error');
return;
}
// 작업자 일괄 추가
const teamResponse = await window.apiCall(
`/tbm/sessions/${createdSessionId}/team/batch`,
'POST',
{ members }
);
if (selectedWorkersForNewTbm.size === 0) {
showToast('최소 1명 이상의 작업자를 선택해주세요.', 'error');
return;
}
if (teamResponse && teamResponse.success) {
showToast(`TBM이 생성되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length}건)`, 'success');
closeTbmModal();
// 작업자별 members 생성 (task_id, workplace_id = null)
const members = [];
selectedWorkersForNewTbm.forEach(workerId => {
members.push({
worker_id: workerId,
project_id: projectId,
work_type_id: workTypeId,
task_id: null,
workplace_category_id: null,
workplace_id: null,
work_detail: null,
is_present: true
});
});
// 목록 새로고침
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadTbmSessionsByDate(sessionData.session_date);
}
try {
const response = await window.apiCall('/tbm/sessions', 'POST', 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 }
);
if (teamResponse && teamResponse.success) {
showToast(`TBM이 생성되었습니다 (작업자 ${members.length}명)`, 'success');
closeTbmModal();
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.');
await loadRecentTbmGroupedByDate();
}
} else {
throw new Error(response.message || '저장에 실패했습니다.');
throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.');
}
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('❌ TBM 세션 저장 오류:', error);
@@ -1502,6 +1633,11 @@ window.openWorkplaceSelect = openWorkplaceSelect;
// 작업장 선택 모달 닫기
function closeWorkplaceSelectModal() {
// 가로모드 오버레이도 닫기
const landscapeOverlay = document.getElementById('landscapeOverlay');
if (landscapeOverlay && landscapeOverlay.style.display !== 'none') {
closeLandscapeMap();
}
document.getElementById('workplaceSelectModal').style.display = 'none';
unlockBodyScroll();
document.getElementById('workplaceSelectionArea').style.display = 'none';
@@ -1578,6 +1714,9 @@ async function selectCategory(categoryId, categoryName) {
document.getElementById('workplaceListSection').style.display = 'none';
document.getElementById('toggleListBtn').style.display = 'inline-flex';
document.getElementById('toggleListBtn').textContent = '리스트로 선택';
// 전체화면 지도 버튼 표시
const triggerBtn = document.getElementById('landscapeTriggerBtn');
if (triggerBtn) triggerBtn.style.display = 'inline-flex';
} else {
// 데스크톱: 리스트도 함께 표시
document.getElementById('workplaceList').style.display = 'flex';
@@ -1876,6 +2015,178 @@ function syncWorkplaceListSelection(workplaceId) {
}
window.syncWorkplaceListSelection = syncWorkplaceListSelection;
// ==================== 가로모드 전체화면 지도 ====================
function openLandscapeMap() {
if (!mapImage || !mapImage.complete || mapRegions.length === 0) return;
const overlay = document.getElementById('landscapeOverlay');
const inner = document.getElementById('landscapeInner');
const lCanvas = document.getElementById('landscapeCanvas');
if (!overlay || !lCanvas) return;
overlay.style.display = 'flex';
lockBodyScroll();
// 물리적 가로모드 여부 판단
const isPhysicalLandscape = window.innerWidth > window.innerHeight;
inner.className = 'landscape-inner ' + (isPhysicalLandscape ? 'no-rotate' : 'rotated');
// 가용 영역 계산 (헤더 52px, 패딩 여유)
const headerH = 52;
const pad = 16;
let availW, availH;
if (isPhysicalLandscape) {
availW = window.innerWidth - pad * 2;
availH = window.innerHeight - headerH - pad * 2;
} else {
// 회전: 가로↔세로 스왑
availW = window.innerHeight - pad * 2;
availH = window.innerWidth - headerH - pad * 2;
}
// 이미지 비율 유지 캔버스 크기
const imgRatio = mapImage.naturalWidth / mapImage.naturalHeight;
let cw, ch;
if (availW / availH > imgRatio) {
ch = availH;
cw = ch * imgRatio;
} else {
cw = availW;
ch = cw / imgRatio;
}
lCanvas.width = Math.round(cw);
lCanvas.height = Math.round(ch);
drawLandscapeMap();
// 이벤트 리스너
lCanvas.ontouchstart = handleLandscapeTouchStart;
lCanvas.onclick = handleLandscapeClick;
}
window.openLandscapeMap = openLandscapeMap;
function drawLandscapeMap() {
const lCanvas = document.getElementById('landscapeCanvas');
if (!lCanvas || !mapImage) return;
const lCtx = lCanvas.getContext('2d');
lCtx.drawImage(mapImage, 0, 0, lCanvas.width, lCanvas.height);
mapRegions.forEach(region => {
const x1 = (region.x_start / 100) * lCanvas.width;
const y1 = (region.y_start / 100) * lCanvas.height;
const x2 = (region.x_end / 100) * lCanvas.width;
const y2 = (region.y_end / 100) * lCanvas.height;
const w = x2 - x1;
const h = y2 - y1;
const isSelected = region.workplace_id === selectedWorkplace;
lCtx.strokeStyle = isSelected ? '#3b82f6' : '#10b981';
lCtx.lineWidth = isSelected ? 4 : 2;
lCtx.strokeRect(x1, y1, w, h);
lCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.25)' : 'rgba(16, 185, 129, 0.15)';
lCtx.fillRect(x1, y1, w, h);
if (region.workplace_name) {
lCtx.font = 'bold 14px sans-serif';
const tm = lCtx.measureText(region.workplace_name);
const tp = 6;
lCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.95)' : 'rgba(16, 185, 129, 0.95)';
lCtx.fillRect(x1 + 5, y1 + 5, tm.width + tp * 2, 24);
lCtx.fillStyle = '#ffffff';
lCtx.textAlign = 'left';
lCtx.textBaseline = 'alphabetic';
lCtx.fillText(region.workplace_name, x1 + 5 + tp, y1 + 22);
}
});
}
function getLandscapeCoords(clientX, clientY) {
const lCanvas = document.getElementById('landscapeCanvas');
if (!lCanvas) return null;
const rect = lCanvas.getBoundingClientRect();
const inner = document.getElementById('landscapeInner');
const isRotated = inner.classList.contains('rotated');
if (!isRotated) {
// 회전 없음 - 일반 좌표
const scaleX = lCanvas.width / rect.width;
const scaleY = lCanvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
}
// 90° 시계방향 회전의 역변환
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = clientX - centerX;
const dy = clientY - centerY;
// 역회전 (반시계 90°)
const inverseDx = dy;
const inverseDy = -dx;
// 회전 전 실제 크기: rect가 회전된 후이므로 width↔height 스왑
const unrotatedW = rect.height;
const unrotatedH = rect.width;
const canvasX = (inverseDx + unrotatedW / 2) / unrotatedW * lCanvas.width;
const canvasY = (inverseDy + unrotatedH / 2) / unrotatedH * lCanvas.height;
return { x: canvasX, y: canvasY };
}
function handleLandscapeTouchStart(e) {
e.preventDefault(); // 고스트 클릭 방지
const touch = e.touches[0];
const coords = getLandscapeCoords(touch.clientX, touch.clientY);
if (coords) doLandscapeHitTest(coords.x, coords.y);
}
function handleLandscapeClick(e) {
const coords = getLandscapeCoords(e.clientX, e.clientY);
if (coords) doLandscapeHitTest(coords.x, coords.y);
}
function doLandscapeHitTest(cx, cy) {
const lCanvas = document.getElementById('landscapeCanvas');
if (!lCanvas) return;
for (let i = mapRegions.length - 1; i >= 0; i--) {
const region = mapRegions[i];
const x1 = (region.x_start / 100) * lCanvas.width;
const y1 = (region.y_start / 100) * lCanvas.height;
const x2 = (region.x_end / 100) * lCanvas.width;
const y2 = (region.y_end / 100) * lCanvas.height;
if (cx >= x1 && cx <= x2 && cy >= y1 && cy <= y2) {
selectWorkplace(region.workplace_id, region.workplace_name);
drawWorkplaceMap();
syncWorkplaceListSelection(region.workplace_id);
// 하이라이트 후 자동 닫기
drawLandscapeMap();
setTimeout(() => closeLandscapeMap(), 300);
return;
}
}
}
function closeLandscapeMap() {
const overlay = document.getElementById('landscapeOverlay');
if (overlay) overlay.style.display = 'none';
const lCanvas = document.getElementById('landscapeCanvas');
if (lCanvas) {
lCanvas.ontouchstart = null;
lCanvas.onclick = null;
}
unlockBodyScroll();
}
window.closeLandscapeMap = closeLandscapeMap;
// ==================== 기존 팀 구성 모달 (백업) ====================
// 팀 구성 모달 열기
@@ -2274,8 +2585,11 @@ async function saveSafetyChecklist() {
}
window.saveSafetyChecklist = saveSafetyChecklist;
// TBM 완료 모달용 팀원 데이터
let completeModalTeam = [];
// TBM 완료 모달 열기
function openCompleteTbmModal(sessionId) {
async function openCompleteTbmModal(sessionId) {
currentSessionId = sessionId;
const now = new Date();
const timeString = now.toTimeString().slice(0, 5);
@@ -2283,9 +2597,75 @@ function openCompleteTbmModal(sessionId) {
document.getElementById('completeModal').style.display = 'flex';
lockBodyScroll();
// 팀원 조회 → 근태 선택 렌더링
try {
const teamRes = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
completeModalTeam = (teamRes && teamRes.data) ? teamRes.data : [];
renderCompleteAttendanceList();
} catch (e) {
console.error('팀원 조회 오류:', e);
document.getElementById('completeAttendanceList').innerHTML =
'<div style="color:#ef4444; padding:0.5rem;">팀원 목록을 불러올 수 없습니다.</div>';
}
}
window.openCompleteTbmModal = openCompleteTbmModal;
function renderCompleteAttendanceList() {
const container = document.getElementById('completeAttendanceList');
if (completeModalTeam.length === 0) {
container.innerHTML = '<div style="color:#9ca3af; padding:0.5rem; text-align:center;">팀원이 없습니다.</div>';
return;
}
let html = '<table style="width:100%; border-collapse:collapse; font-size:0.8125rem;">' +
'<tr style="background:#f9fafb;"><th style="padding:0.5rem; text-align:left;">작업자</th><th style="padding:0.5rem; text-align:left;">직종</th><th style="padding:0.5rem; text-align:left;">근태</th><th style="padding:0.5rem; text-align:center;">추가</th></tr>';
completeModalTeam.forEach((m, i) => {
html += `<tr style="border-top:1px solid #f3f4f6;">
<td style="padding:0.5rem; font-weight:600;">${m.worker_name || ''}</td>
<td style="padding:0.5rem; color:#6b7280;">${m.job_type || '-'}</td>
<td style="padding:0.5rem;">
<select id="catt_type_${i}" onchange="onCompleteAttChange(${i})" style="padding:0.375rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.8125rem; background:white;">
<option value="regular">정시근로 (8h)</option>
<option value="overtime">연장근무 (8h+)</option>
<option value="annual">연차 (휴무)</option>
<option value="half">반차 (4h)</option>
<option value="quarter">반반차 (6h)</option>
<option value="early">조퇴</option>
</select>
</td>
<td style="padding:0.5rem; text-align:center;">
<input type="number" id="catt_hours_${i}" step="0.5" min="0" max="8" style="display:none; width:60px; padding:0.375rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.8125rem; text-align:center;">
<span id="catt_hint_${i}" style="font-size:0.75rem; color:#6b7280;"></span>
</td>
</tr>`;
});
html += '</table>';
container.innerHTML = html;
}
window.onCompleteAttChange = function(idx) {
const sel = document.getElementById('catt_type_' + idx);
const inp = document.getElementById('catt_hours_' + idx);
const hint = document.getElementById('catt_hint_' + idx);
const val = sel.value;
if (val === 'overtime') {
inp.style.display = 'inline-block';
inp.placeholder = '+h';
inp.value = '';
hint.textContent = '';
} else if (val === 'early') {
inp.style.display = 'inline-block';
inp.placeholder = '시간';
inp.value = '';
hint.textContent = '';
} else {
inp.style.display = 'none';
inp.value = '';
const labels = { regular: '8h', annual: '자동처리', half: '4h', quarter: '6h' };
hint.textContent = labels[val] || '';
}
};
// 완료 모달 닫기
function closeCompleteModal() {
document.getElementById('completeModal').style.display = 'none';
@@ -2297,11 +2677,37 @@ window.closeCompleteModal = closeCompleteModal;
async function completeTbmSession() {
const endTime = document.getElementById('endTime').value;
// 근태 데이터 수집
const attendanceData = [];
for (let i = 0; i < completeModalTeam.length; i++) {
const type = document.getElementById('catt_type_' + i).value;
const hoursVal = document.getElementById('catt_hours_' + i).value;
const hours = hoursVal ? parseFloat(hoursVal) : null;
if (type === 'overtime' && (!hours || hours <= 0)) {
showToast(`${completeModalTeam[i].worker_name}의 추가 시간을 입력해주세요.`, 'error');
return;
}
if (type === 'early' && (!hours || hours <= 0)) {
showToast(`${completeModalTeam[i].worker_name}의 근무 시간을 입력해주세요.`, 'error');
return;
}
attendanceData.push({
worker_id: completeModalTeam[i].worker_id,
attendance_type: type,
attendance_hours: hours
});
}
const btn = document.getElementById('completeModalBtn');
if (btn) { btn.disabled = true; btn.textContent = '처리 중...'; }
try {
const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/complete`,
'POST',
{ end_time: endTime }
{ end_time: endTime, attendance_data: attendanceData }
);
if (response && response.success) {
@@ -2321,6 +2727,8 @@ async function completeTbmSession() {
} catch (error) {
console.error('❌ TBM 완료 처리 오류:', error);
showToast('TBM 완료 처리 중 오류가 발생했습니다.', 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<span class="tbm-btn-icon">&#10003;</span> 완료'; }
}
}
window.completeTbmSession = completeTbmSession;
@@ -2365,8 +2773,8 @@ async function viewTbmSession(sessionId) {
<div style="font-weight: 600; color: #111827;">${escapeHtml(statusText)}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀원 </div>
<div style="font-weight: 600; color: #111827;">${parseInt(session.team_member_count) || team.length}</div>
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀원 (${parseInt(session.team_member_count) || team.length}명)</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(session.team_member_names || team.map(t => t.worker_name).join(', ') || '없음')}</div>
</div>
${session.project_name ? `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">

View File

@@ -214,10 +214,11 @@ class TbmAPI {
if (!this.state.isAdminUser()) {
const userId = this.state.currentUser?.user_id;
const workerId = this.state.currentUser?.worker_id;
const userName = this.state.currentUser?.name;
sessions = sessions.filter(s => {
return s.created_by === userId ||
s.leader_id === workerId ||
s.created_by_name === this.state.currentUser?.name;
return (userId && String(s.created_by) === String(userId)) ||
(workerId && String(s.leader_id) === String(workerId)) ||
(userName && s.created_by_name === userName);
});
}

View File

@@ -104,7 +104,8 @@ class TbmState {
isAdminUser() {
const user = this.getUser();
if (!user) return false;
return user.role === 'Admin' || user.role === 'System Admin';
const role = (user.role || '').toLowerCase();
return role === 'admin' || role === 'system admin' || role === 'system';
}
/**