Files
tk-factory-services/system1-factory/web/js/tbm-create.js
Hyungi Ahn f09c86ee01 fix(security): CRITICAL 보안 이슈 13건 일괄 수정
- SEC-42: JWT algorithm HS256 명시 (sign 5곳, verify 3곳)
- SEC-44: MariaDB/PhpMyAdmin 포트 127.0.0.1 바인딩
- SEC-29: escHtml = escapeHtml alias 추가 (XSS 방지)
- SEC-39: Python Dockerfile 4개 non-root user + chown
- SEC-43: deploy-remote.sh 삭제 (평문 비밀번호 포함)
- SEC-11,12: SQL SET ? → 명시적 컬럼 whitelist + IN절 parameterized
- QA-34: vacation approveRequest/cancelRequest 트랜잭션 래핑
- SEC-32,34: material_comparison.py 5개 엔드포인트 인증 + confirmed_by
- SEC-33: files.py 17개 미인증 엔드포인트 인증 추가
- SEC-37: chatbot 프롬프트 인젝션 방어 (sanitize + XML 구분자)
- SEC-38: fastapi-bridge 프록시 JWT 검증 + 캐시 키 user_id 포함
- SEC-58/QA-98: monthly-comparison API_BASE_URL 수정 + 401 처리
- SEC-61: monthlyComparisonModel SELECT FOR UPDATE 추가
- SEC-63: proxyInputController 에러 메시지 노출 제거
- QA-103: pageAccessRoutes error→message 통일
- SEC-62: tbm-create onclick 인젝션 → data-attribute event delegation
- QA-99: tbm-mobile/create 캐시 버스팅 갱신
- QA-100,101: ESC 키 리스너 cleanup 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:48:58 +09:00

626 lines
22 KiB
JavaScript

/**
* 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(), // user_id Set
workerNames: {}, // { user_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 waitForApi();
// 초기 데이터 로드
await window.TbmAPI.loadInitialData();
// 기본 정보 자동 설정
W.sessionDate = window.TbmUtils.getTodayKST();
var user = window.TbmState.getUser();
if (user) {
var uid = user.user_id || user.id;
if (uid) {
var worker = window.TbmState.allWorkers.find(function(w) { return String(w.user_id) === String(uid); });
if (worker) {
W.leaderId = worker.user_id;
W.leaderName = worker.worker_name;
} else {
W.leaderId = uid;
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');
}
});
// waitForApi → api-base.js 전역 사용
// ==================== 네비게이션 ====================
window.nextStep = function() {
console.log('[TBM Create] nextStep called, current step:', W.step, 'workTypeId:', W.workTypeId);
if (!validateStep(W.step)) return;
if (W.step < W.totalSteps) {
W.step++;
renderStep(W.step);
updateIndicator();
updateNav();
window.scrollTo(0, 0);
}
};
window.prevStep = function() {
console.log('[TBM Create] prevStep called, current step:', W.step);
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';
});
}
// 네비게이션 버튼: 단일 핸들러 (DOM 교체 없이 상태 기반 분기)
var _navAction = { prev: null, next: null };
function updateNav() {
var prevBtn = document.getElementById('prevBtn');
var nextBtn = document.getElementById('nextBtn');
if (W.step === 1) {
prevBtn.style.visibility = 'hidden';
_navAction.prev = null;
} else {
prevBtn.style.visibility = 'visible';
_navAction.prev = window.prevStep;
}
if (W.step === W.totalSteps) {
nextBtn.className = 'nav-btn nav-btn-save';
nextBtn.textContent = '저장';
_navAction.next = saveWizard;
} else {
nextBtn.className = 'nav-btn nav-btn-next';
nextBtn.innerHTML = '다음 &#8594;';
_navAction.next = window.nextStep;
}
nextBtn.disabled = false;
}
// 한번만 등록하는 이벤트 리스너
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('prevBtn').addEventListener('click', function(e) {
e.preventDefault();
if (_navAction.prev) _navAction.prev();
});
document.getElementById('nextBtn').addEventListener('click', function(e) {
e.preventDefault();
if (_navAction.next) _navAction.next();
});
});
// ==================== 유효성 검사 ====================
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.user_id] = a;
}
});
} else {
W.todayAssignments = {};
}
} catch(e) {
console.error('배정 현황 로드 오류:', e);
W.todayAssignments = {};
}
}
var workerCards = workers.map(function(w) {
var selected = W.workers.has(w.user_id) ? ' selected' : '';
var assignment = W.todayAssignments[w.user_id];
var assigned = assignment && assignment.sessions && assignment.sessions.length > 0;
var badgeHtml = '';
var disabledClass = '';
var onclick = 'toggleWorker(' + w.user_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</div>';
disabledClass = ' disabled';
onclick = '';
}
return '<div class="worker-card' + selected + disabledClass + '"' +
(onclick ? ' onclick="' + onclick + '"' : '') +
' data-wid="' + w.user_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.sessions && a.sessions.length > 0) 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.user_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.user_id];
return !(a && a.sessions && a.sessions.length > 0);
});
if (W.workers.size === availableWorkers.length) {
W.workers.clear();
W.workerNames = {};
} else {
availableWorkers.forEach(function(w) {
W.workers.add(w.user_id);
W.workerNames[w.user_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 + '" data-action="selectProject" data-project-id="' + p.project_id + '" data-project-name="' + esc(p.project_name) + '">' +
'<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 + '" data-action="selectWorkType" data-wt-id="' + wt.id + '" data-wt-name="' + esc(wt.name) + '">' + 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 + '" data-action="selectProject" data-project-id="" data-project-name="">' +
'선택 안함' +
'</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(); }
};
}
}
// Event delegation for project/workType selection
container.onclick = function(e) {
var el = e.target.closest('[data-action]');
if (!el) return;
var action = el.getAttribute('data-action');
if (action === 'selectProject') {
var pid = el.getAttribute('data-project-id');
selectProject(pid ? parseInt(pid) : null, el.getAttribute('data-project-name') || '');
} else if (action === 'selectWorkType') {
selectWorkType(parseInt(el.getAttribute('data-wt-id')), el.getAttribute('data-wt-name') || '');
}
};
}
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) {
console.log('[TBM Create] selectWorkType:', 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>';
}
// ==================== 저장 ====================
var _saving = false;
async function saveWizard() {
if (_saving) return;
_saving = true;
// 로딩 오버레이 표시
var overlay = document.getElementById('loadingOverlay');
var loadingText = document.getElementById('loadingText');
if (overlay) {
if (loadingText) loadingText.textContent = '저장 중...';
overlay.style.display = 'flex';
}
// 저장 버튼 비활성화
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_user_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({
user_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) {
var err = new Error(teamResponse?.message || '팀원 추가 실패');
if (teamResponse && teamResponse.duplicates) err.duplicates = teamResponse.duplicates;
err._sessionId = sessionId;
throw err;
}
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);
// 409 중복 배정 에러 처리
if (error.duplicates && error.duplicates.length > 0) {
// 고아 세션 삭제
if (error._sessionId) {
try { await window.apiCall('/tbm/sessions/' + error._sessionId, 'DELETE'); } catch(e) {}
}
// 중복 작업자 자동 해제
error.duplicates.forEach(function(d) {
W.workers.delete(d.user_id);
delete W.workerNames[d.user_id];
});
// 배정 현황 캐시 갱신
W.todayAssignments = null;
// Step 1로 복귀
W.step = 1;
renderStep(1);
updateIndicator();
updateNav();
showToast(error.message, 'error');
} else {
showToast('TBM 저장 중 오류가 발생했습니다: ' + error.message, 'error');
}
if (overlay) overlay.style.display = 'none';
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = '저장';
}
_saving = false;
}
}
// ==================== 토스트 (로컬) ====================
function showToast(message, type) {
if (window.showToast && typeof window.showToast === 'function') {
window.showToast(message, type);
return;
}
console.log('[Toast] ' + type + ': ' + message);
}
})();