Files
tk-factory-services/system1-factory/web/js/tbm-mobile.js
Hyungi Ahn abd7564e6b refactor: worker_id → user_id 전체 마이그레이션 (Phase 1-4)
sso_users.user_id를 단일 식별자로 통합. JWT에서 worker_id 제거,
department_id/is_production 추가. 백엔드 15개 모델, 11개 컨트롤러,
4개 서비스, 7개 라우트, 프론트엔드 32+ JS/11+ HTML 변환.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:13:10 +09:00

1330 lines
52 KiB
JavaScript

/**
* TBM Mobile - Main UI Logic
* tbm-mobile.html에서 추출된 인라인 JS (로직 변경 없음)
*/
(function() {
'use strict';
var currentTab = 'today';
var allSessions = [];
var todaySessions = [];
var currentUser = null;
var loadedDays = 7;
var esc = window.escapeHtml || function(s) { return s || ''; };
var todayAssignments = []; // 당일 배정 현황
// 세부 편집 상태
var deSessionId = null;
var deSession = null;
var deMembers = [];
var deTasks = [];
var deWpCats = [];
var deWpMap = {}; // category_id -> [workplaces]
var deSelected = {}; // index -> boolean (그룹 선택용)
// 피커 상태
var pickerMode = ''; // 'task' | 'workplace'
var pickerWpStep = 'category'; // 'category' | 'place'
var pickerSelectedCatId = null;
// busy guard - 비동기 함수 중복 호출 방지
var _busy = {};
function isBusy(key) { return !!_busy[key]; }
function setBusy(key) { _busy[key] = true; }
function clearBusy(key) { delete _busy[key]; }
function showLoading(msg) {
var el = document.getElementById('loadingOverlay');
if (el) {
document.getElementById('loadingText').textContent = msg || '불러오는 중...';
el.classList.add('active');
}
}
function hideLoading() {
var el = document.getElementById('loadingOverlay');
if (el) el.classList.remove('active');
}
// 초기화
document.addEventListener('DOMContentLoaded', async function() {
var now = new Date();
var days = ['일','월','화','수','목','금','토'];
var dateEl = document.getElementById('headerDate');
if (dateEl) {
dateEl.textContent = now.getFullYear() + '.' +
String(now.getMonth()+1).padStart(2,'0') + '.' +
String(now.getDate()).padStart(2,'0') + ' (' + days[now.getDay()] + ')';
}
try {
await window.waitForApi(8000);
} catch(e) {
document.getElementById('tbmContent').innerHTML =
'<div class="m-empty"><div class="m-empty-icon">&#9888;</div><div class="m-empty-text">서버 연결에 실패했습니다</div><div class="m-empty-sub">페이지를 새로고침해 주세요</div></div>';
return;
}
currentUser = JSON.parse(localStorage.getItem('sso_user') || '{}');
await loadData();
});
function getTodayStr() {
var now = new Date();
return now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0');
}
async function loadData() {
try {
var today = new Date();
var todayStr = getTodayStr();
var dates = [];
for (var i = 0; i < loadedDays; i++) {
var d = new Date(today);
d.setDate(d.getDate() - i);
dates.push(d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'));
}
var API = window.TbmAPI;
var promises = dates.map(function(date) {
return API.fetchSessionsByDate(date);
});
var results = await Promise.all(promises);
allSessions = [];
results.forEach(function(sessions) {
if (sessions && sessions.length > 0) {
allSessions = allSessions.concat(sessions);
}
});
// 당일 세션 = 오늘 날짜만
todaySessions = allSessions.filter(function(s) {
var sDate = s.session_date ? s.session_date.split('T')[0] : '';
return sDate === todayStr;
});
document.getElementById('todayCount').textContent = todaySessions.length;
document.getElementById('allCount').textContent = allSessions.length;
renderList();
} catch (error) {
console.error('TBM 로드 오류:', error);
document.getElementById('tbmContent').innerHTML =
'<div class="m-empty"><div class="m-empty-text">데이터를 불러올 수 없습니다</div></div>';
}
}
window.switchTab = function(tab) {
currentTab = tab;
document.querySelectorAll('.m-tab').forEach(function(el) {
el.classList.toggle('active', el.dataset.tab === tab);
});
renderList();
};
function isMySession(s) {
var userId = currentUser.user_id;
var workerId = currentUser.user_id;
var userName = currentUser.name;
return (userId && String(s.created_by) === String(userId)) ||
(workerId && String(s.leader_user_id) === String(workerId)) ||
(userName && s.created_by_name === userName);
}
function renderList() {
var sessions = currentTab === 'today' ? todaySessions : allSessions;
var content = document.getElementById('tbmContent');
if (sessions.length === 0) {
var emptyMsg = currentTab === 'today' ?
'오늘 등록된 TBM이 없습니다' : '등록된 TBM이 없습니다';
content.innerHTML =
'<div class="m-empty">' +
'<div class="m-empty-icon">&#128221;</div>' +
'<div class="m-empty-text">' + emptyMsg + '</div>' +
(currentTab === 'all' ? '<div class="m-empty-sub">최근 ' + loadedDays + '일 기준</div>' : '') +
'</div>';
return;
}
var grouped = {};
sessions.forEach(function(s) {
var date = s.session_date ? s.session_date.split('T')[0] : '';
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { /* ok */ }
else if (s.session_date) { date = new Date(s.session_date).toISOString().split('T')[0]; }
if (!grouped[date]) grouped[date] = [];
grouped[date].push(s);
});
var sortedDates = Object.keys(grouped).sort().reverse();
var todayStr = getTodayStr();
var yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
var html = '';
sortedDates.forEach(function(date) {
var label = date;
if (date === todayStr) label = '오늘';
else if (date === yesterday) label = '어제';
else {
var parts = date.split('-');
var dayNames = ['일','월','화','수','목','금','토'];
var dObj = new Date(date + 'T00:00:00');
label = parseInt(parts[1]) + '/' + parseInt(parts[2]) + ' (' + dayNames[dObj.getDay()] + ')';
}
html += '<div class="m-date-group"><span class="m-date-label">' + label + '</span></div>';
grouped[date].forEach(function(s) {
var sid = s.session_id;
var status = s.status || 'draft';
var leaderName = s.leader_name || s.created_by_name || '미지정';
var memberCount = (parseInt(s.team_member_count) || 0);
var memberNames = s.team_member_names || '';
var subText = memberNames || '팀원 없음';
var isMine = isMySession(s);
var transferCount = parseInt(s.transfer_count) || 0;
var createdTime = '';
if (s.created_at) {
try {
var t = new Date(s.created_at);
createdTime = String(t.getHours()).padStart(2,'0') + ':' + String(t.getMinutes()).padStart(2,'0');
} catch(e) {}
}
var statusLabel = status === 'completed' ? '완료' : (status === 'cancelled' ? '취소' : '진행');
var badge = '';
if (status === 'draft') {
if (!s.task_id) {
badge = '<span class="m-detail-badge incomplete">세부 미입력</span>';
} else {
badge = '<span class="m-detail-badge complete">입력 완료</span>';
}
}
// 이동 뱃지
var transferBadge = '';
if (transferCount > 0) {
transferBadge = '<span class="m-transfer-badge">' + transferCount + '건 이동</span>';
}
// 당일 탭에서 다른 반장의 draft TBM 클릭 → 빼오기 시트
var clickAction;
if (isMine && status === 'draft') {
clickAction = 'openDetailEditSheet(' + sid + ')';
} else if (!isMine && status === 'draft' && currentTab === 'today') {
clickAction = 'openPullSheet(' + sid + ')';
} else if (status !== 'draft') {
clickAction = 'toggleDetail(' + sid + ')';
} else {
clickAction = 'toggleDetail(' + sid + ')';
}
var myTbmClass = isMine ? ' my-tbm' : '';
var leaderDisplay = esc(leaderName);
if (!isMine && currentTab === 'today') {
leaderDisplay += '<span class="m-leader-badge">타 반장</span>';
}
html += '<div class="m-tbm-row' + myTbmClass + '" data-sid="' + sid + '" onclick="' + clickAction + '">' +
'<div class="m-row-status ' + status + '" title="' + statusLabel + '"></div>' +
'<div class="m-row-body">' +
'<div class="m-row-main">' + leaderDisplay + badge + transferBadge + '</div>' +
'<div class="m-row-sub">' + esc(subText) + '</div>' +
'</div>' +
'<div class="m-row-right">' +
'<div class="m-row-count">' + memberCount + '<span class="m-row-count-label">명</span></div>' +
(createdTime ? '<div class="m-row-time">' + createdTime + '</div>' : '') +
'</div>' +
'</div>';
if (status !== 'draft') {
var taskName = s.task_name || '';
var workplaceName = s.work_location || '';
html += '<div class="m-tbm-detail" id="detail_' + sid + '">' +
'<div class="m-detail-row"><span class="m-detail-label">상태</span><span class="m-detail-value">' + statusLabel + '</span></div>' +
'<div class="m-detail-row"><span class="m-detail-label">입력자</span><span class="m-detail-value">' + esc(leaderName) + '</span></div>' +
(taskName ? '<div class="m-detail-row"><span class="m-detail-label">작업</span><span class="m-detail-value">' + esc(taskName) + '</span></div>' : '') +
(workplaceName ? '<div class="m-detail-row"><span class="m-detail-label">장소</span><span class="m-detail-value">' + esc(workplaceName) + '</span></div>' : '') +
'<div class="m-detail-row"><span class="m-detail-label">인원</span><span class="m-detail-value">' + esc(memberNames || '없음') + ' (' + memberCount + '명)</span></div>' +
'<div class="m-detail-actions">' +
'<button type="button" class="m-detail-btn" onclick="event.stopPropagation(); openDetailEditSheet(' + sid + ');">상세보기</button>' +
'</div>' +
'</div>';
}
});
});
if (currentTab === 'all') {
html += '<button type="button" class="m-load-more" onclick="loadMore()">이전 기록 더 보기</button>';
}
content.innerHTML = html;
}
window.toggleDetail = function(sid) {
var row = document.querySelector('.m-tbm-row[data-sid="' + sid + '"]');
if (!row) return;
document.querySelectorAll('.m-tbm-row.expanded').forEach(function(el) {
if (el !== row) el.classList.remove('expanded');
});
row.classList.toggle('expanded');
};
window.loadMore = function() {
loadedDays += 7;
loadData();
};
// ─── 세부 편집 바텀시트 ───
window.openDetailEditSheet = async function(sid) {
if (isBusy('detailEdit')) return;
setBusy('detailEdit');
showLoading('불러오는 중...');
deSessionId = sid;
deSelected = {};
try {
var API = window.TbmAPI;
var results = await Promise.all([
API.getSession(sid).catch(function() { return null; }),
API.getTeamMembers(sid).catch(function() { return []; }),
API.loadTasks().catch(function() { return []; }),
API.loadWorkplaceCategories().catch(function() { return []; }),
API.loadActiveWorkplacesList().catch(function() { return []; })
]);
deSession = results[0];
deMembers = results[1];
deTasks = results[2] || window.TbmState.allTasks || [];
deWpCats = results[3] || window.TbmState.allWorkplaceCategories || [];
var allWorkplaces = results[4];
if (!deSession) { window.showToast('TBM 정보를 불러올 수 없습니다.', 'error'); return; }
if (deMembers.length === 0) { window.showToast('팀원이 없습니다.', 'error'); return; }
// work_type 필터
var workTypeId = deSession.work_type_id || (deMembers[0] && deMembers[0].work_type_id);
if (workTypeId) {
deTasks = deTasks.filter(function(t) { return t.work_type_id == workTypeId; });
}
// 작업장소 맵 (category_id 기준)
deWpMap = {};
allWorkplaces.forEach(function(wp) {
var catId = wp.category_id || 0;
if (!deWpMap[catId]) deWpMap[catId] = [];
deWpMap[catId].push(wp);
});
renderDetailEditSheet();
document.getElementById('deSelectAll').checked = false;
updateGroupBar();
document.getElementById('detailEditOverlay').style.display = 'block';
document.getElementById('detailEditSheet').style.display = 'block';
} catch(e) {
console.error('세부 편집 로드 오류:', e);
window.showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
} finally {
hideLoading();
clearBusy('detailEdit');
}
};
function renderDetailEditSheet() {
var html = '';
deMembers.forEach(function(m, i) {
var hasBoth = m.task_id && m.workplace_id;
var cardClass = hasBoth ? 'filled' : 'unfilled';
var statusHtml = hasBoth
? '<span class="de-worker-status ok">입력완료</span>'
: '<span class="de-worker-status missing">미입력</span>';
// work_hours 표시
var workHoursTag = '';
if (m.work_hours !== null && m.work_hours !== undefined) {
workHoursTag = '<span class="m-work-hours-tag">' + parseFloat(m.work_hours) + 'h</span>';
}
// 분할 항목이면 프로젝트명 표시
var projectTag = '';
if (m.split_seq > 0 && m.project_name) {
projectTag = '<span style="font-size:0.625rem; background:#dbeafe; color:#1e40af; padding:0.0625rem 0.375rem; border-radius:0.25rem; margin-left:0.25rem;">' + esc(m.project_name) + '</span>';
} else if (m.project_name && m.project_id !== deSession.project_id) {
projectTag = '<span style="font-size:0.625rem; background:#fef3c7; color:#92400e; padding:0.0625rem 0.375rem; border-radius:0.25rem; margin-left:0.25rem;">' + esc(m.project_name) + '</span>';
}
var taskOptions = '<option value="">작업 선택...</option>';
deTasks.forEach(function(t) {
var sel = (m.task_id && m.task_id == t.task_id) ? ' selected' : '';
taskOptions += '<option value="' + t.task_id + '"' + sel + '>' + esc(t.task_name) + '</option>';
});
var currentCatId = m.workplace_category_id || '';
var catOptions = '<option value="">분류...</option>';
deWpCats.forEach(function(c) {
var sel = (currentCatId && currentCatId == c.category_id) ? ' selected' : '';
catOptions += '<option value="' + c.category_id + '"' + sel + '>' + esc(c.category_name) + '</option>';
});
var wpOptions = '<option value="">장소...</option>';
if (currentCatId && deWpMap[currentCatId]) {
deWpMap[currentCatId].forEach(function(wp) {
var sel = (m.workplace_id && m.workplace_id == wp.workplace_id) ? ' selected' : '';
wpOptions += '<option value="' + wp.workplace_id + '"' + sel + '>' + esc(wp.workplace_name) + '</option>';
});
}
html += '<div class="de-worker-card ' + cardClass + '" id="de_card_' + i + '">' +
'<div style="display:flex; align-items:center;">' +
'<input type="checkbox" class="de-worker-check" id="de_check_' + i + '" onchange="onWorkerCheck(' + i + ')">' +
'<span class="de-worker-name">' + esc(m.worker_name) + '</span> ' +
'<span class="de-worker-job">' + esc(m.job_type || '') + '</span>' +
workHoursTag +
projectTag +
statusHtml +
'<button type="button" class="de-split-btn" onclick="event.stopPropagation(); openSplitSheet(' + i + ')">분할</button>' +
'</div>' +
'<div class="de-worker-fields">' +
'<div class="de-field-row">' +
'<span class="de-field-label">작업</span>' +
'<select id="de_task_' + i + '" onchange="updateCardStatus(' + i + ')">' + taskOptions + '</select>' +
'</div>' +
'<div class="de-field-row">' +
'<span class="de-field-label">장소</span>' +
'<select id="de_wpcat_' + i + '" onchange="onDeWpCatChange(' + i + ')">' + catOptions + '</select>' +
'<select id="de_wp_' + i + '" onchange="updateCardStatus(' + i + ')">' + wpOptions + '</select>' +
'</div>' +
'</div>' +
'</div>';
});
document.getElementById('deWorkerList').innerHTML = html;
}
window.updateCardStatus = function(idx) {
var card = document.getElementById('de_card_' + idx);
var taskVal = document.getElementById('de_task_' + idx).value;
var wpVal = document.getElementById('de_wp_' + idx).value;
var statusEl = card.querySelector('.de-worker-status');
if (taskVal && wpVal) {
card.className = 'de-worker-card filled';
statusEl.className = 'de-worker-status ok';
statusEl.textContent = '입력완료';
} else {
card.className = 'de-worker-card unfilled';
statusEl.className = 'de-worker-status missing';
statusEl.textContent = '미입력';
}
};
window.onDeWpCatChange = function(idx) {
var catId = document.getElementById('de_wpcat_' + idx).value;
var wpSel = document.getElementById('de_wp_' + idx);
wpSel.innerHTML = '<option value="">장소...</option>';
if (catId && deWpMap[catId]) {
deWpMap[catId].forEach(function(wp) {
wpSel.innerHTML += '<option value="' + wp.workplace_id + '">' + esc(wp.workplace_name) + '</option>';
});
}
updateCardStatus(idx);
};
// ─── 그룹 선택 ───
window.onWorkerCheck = function(idx) {
deSelected[idx] = document.getElementById('de_check_' + idx).checked;
var allChecked = true;
for (var i = 0; i < deMembers.length; i++) {
if (!deSelected[i]) { allChecked = false; break; }
}
document.getElementById('deSelectAll').checked = allChecked;
updateGroupBar();
};
window.toggleSelectAll = function() {
var checked = document.getElementById('deSelectAll').checked;
for (var i = 0; i < deMembers.length; i++) {
deSelected[i] = checked;
document.getElementById('de_check_' + i).checked = checked;
}
updateGroupBar();
};
function getSelectedIndices() {
var arr = [];
for (var i = 0; i < deMembers.length; i++) {
if (deSelected[i]) arr.push(i);
}
return arr;
}
function updateGroupBar() {
var indices = getSelectedIndices();
var bar = document.getElementById('deGroupBar');
var countEl = document.getElementById('deSelectedCount');
var labelEl = document.getElementById('deGroupLabel');
if (indices.length > 0) {
bar.className = 'de-group-bar visible';
labelEl.textContent = indices.length + '명 선택';
countEl.textContent = indices.length + '명';
} else {
bar.className = 'de-group-bar';
countEl.textContent = '';
}
}
// ─── 피커 (작업/장소 선택 팝업) ───
window.openPicker = function(mode) {
var indices = getSelectedIndices();
if (indices.length === 0) {
window.showToast('작업자를 먼저 선택하세요.', 'error');
return;
}
pickerMode = mode;
pickerWpStep = 'category';
pickerSelectedCatId = null;
if (mode === 'task') {
renderTaskPicker();
} else {
renderWorkplaceCatPicker();
}
document.getElementById('pickerOverlay').style.display = 'block';
document.getElementById('pickerSheet').style.display = 'block';
};
window.closePicker = function() {
document.getElementById('pickerOverlay').style.display = 'none';
document.getElementById('pickerSheet').style.display = 'none';
};
function renderTaskPicker() {
document.getElementById('pickerTitle').textContent = '작업 선택';
var listEl = document.getElementById('pickerList');
var html = '';
deTasks.forEach(function(t) {
html += '<div class="picker-item" onclick="pickTask(' + t.task_id + ')">' +
esc(t.task_name) +
'</div>';
});
if (deTasks.length === 0) {
html = '<div style="padding:1.5rem; text-align:center; color:#9ca3af;">등록된 작업이 없습니다</div>';
}
listEl.innerHTML = html;
// 새 작업 추가 영역
var addRow = document.getElementById('pickerAddRow');
addRow.style.display = 'flex';
document.getElementById('pickerAddInput').placeholder = '새 작업명 입력...';
document.getElementById('pickerAddInput').value = '';
document.getElementById('pickerAddBtn').onclick = function() { addNewTask(); };
}
function renderWorkplaceCatPicker() {
pickerWpStep = 'category';
document.getElementById('pickerTitle').textContent = '장소 분류 선택';
var listEl = document.getElementById('pickerList');
var html = '';
deWpCats.forEach(function(c) {
var count = deWpMap[c.category_id] ? deWpMap[c.category_id].length : 0;
html += '<div class="picker-item" onclick="pickWpCategory(' + c.category_id + ')">' +
esc(c.category_name) +
'<span class="picker-item-sub">' + count + '개 장소</span>' +
'</div>';
});
if (deWpCats.length === 0) {
html = '<div style="padding:1.5rem; text-align:center; color:#9ca3af;">등록된 분류가 없습니다</div>';
}
listEl.innerHTML = html;
document.getElementById('pickerAddRow').style.display = 'none';
}
function renderWorkplacePicker(catId) {
pickerWpStep = 'place';
pickerSelectedCatId = catId;
var catName = '';
deWpCats.forEach(function(c) { if (c.category_id == catId) catName = c.category_name; });
document.getElementById('pickerTitle').textContent = esc(catName) + ' - 장소 선택';
var listEl = document.getElementById('pickerList');
var workplaces = deWpMap[catId] || [];
var html = '<div class="picker-item" style="color:#6b7280;" onclick="renderWorkplaceCatPicker()">&#8592; 분류 다시 선택</div>';
workplaces.forEach(function(wp) {
html += '<div class="picker-item" onclick="pickWorkplace(' + catId + ',' + wp.workplace_id + ')">' +
esc(wp.workplace_name) +
'</div>';
});
if (workplaces.length === 0) {
html += '<div style="padding:1rem; text-align:center; color:#9ca3af;">등록된 장소가 없습니다</div>';
}
listEl.innerHTML = html;
document.getElementById('pickerAddRow').style.display = 'none';
}
window.pickTask = function(taskId) {
var indices = getSelectedIndices();
indices.forEach(function(i) {
document.getElementById('de_task_' + i).value = taskId;
updateCardStatus(i);
});
closePicker();
window.showToast(indices.length + '명에게 작업 적용', 'success');
};
window.pickWpCategory = function(catId) {
renderWorkplacePicker(catId);
};
window.pickWorkplace = function(catId, wpId) {
var indices = getSelectedIndices();
indices.forEach(function(i) {
// 분류 설정
document.getElementById('de_wpcat_' + i).value = catId;
// 장소 옵션 갱신
var wpSel = document.getElementById('de_wp_' + i);
wpSel.innerHTML = '<option value="">장소...</option>';
if (deWpMap[catId]) {
deWpMap[catId].forEach(function(wp) {
wpSel.innerHTML += '<option value="' + wp.workplace_id + '">' + esc(wp.workplace_name) + '</option>';
});
}
wpSel.value = wpId;
updateCardStatus(i);
});
closePicker();
window.showToast(indices.length + '명에게 장소 적용', 'success');
};
// ─── 새 작업/공정 추가 ───
async function addNewTask() {
var name = document.getElementById('pickerAddInput').value.trim();
if (!name) { window.showToast('작업명을 입력하세요.', 'error'); return; }
var workTypeId = deSession.work_type_id || (deMembers[0] && deMembers[0].work_type_id) || null;
try {
var res = await window.TbmAPI.createTask({
task_name: name,
work_type_id: workTypeId
});
if (res && res.success) {
var newId = res.data.task_id;
// deTasks에 추가
deTasks.push({ task_id: newId, task_name: name, work_type_id: workTypeId });
// 모든 작업자 드롭다운 갱신
for (var i = 0; i < deMembers.length; i++) {
var sel = document.getElementById('de_task_' + i);
var opt = document.createElement('option');
opt.value = newId;
opt.textContent = name;
sel.appendChild(opt);
}
// 피커 다시 렌더링
renderTaskPicker();
window.showToast('작업 "' + name + '" 추가됨', 'success');
} else {
window.showToast('작업 추가 실패', 'error');
}
} catch(e) {
console.error(e);
window.showToast('오류가 발생했습니다.', 'error');
}
}
window.closeDetailEditSheet = function() {
document.getElementById('detailEditOverlay').style.display = 'none';
document.getElementById('detailEditSheet').style.display = 'none';
clearBusy('detailEdit');
};
// 저장 (부분 입력도 허용)
window.saveDetailEdit = async function() {
var members = [];
for (var i = 0; i < deMembers.length; i++) {
var m = deMembers[i];
var taskId = document.getElementById('de_task_' + i).value || null;
var wpCatId = document.getElementById('de_wpcat_' + i).value || null;
var wpId = document.getElementById('de_wp_' + i).value || null;
members.push({
user_id: m.user_id,
project_id: m.project_id || deSession.project_id || null,
work_type_id: m.work_type_id || deSession.work_type_id || null,
task_id: taskId ? parseInt(taskId) : null,
workplace_category_id: wpCatId ? parseInt(wpCatId) : null,
workplace_id: wpId ? parseInt(wpId) : null,
work_detail: m.work_detail || null
});
}
var btn = document.getElementById('deSaveBtn');
btn.disabled = true;
btn.textContent = '저장 중...';
try {
await window.TbmAPI.clearTeamMembers(deSessionId);
var res = await window.TbmAPI.addTeamMembers(deSessionId, members);
if (res && res.success) {
closeDetailEditSheet();
window.showToast('세부 내역이 저장되었습니다.', 'success');
await loadData();
} else {
window.showToast('저장에 실패했습니다.', 'error');
}
} catch(e) {
console.error('세부 편집 저장 오류:', e);
window.showToast('오류가 발생했습니다.', 'error');
} finally {
btn.disabled = false;
btn.textContent = '저장';
}
};
// 완료 (미입력 있으면 차단)
window.completeFromDetailSheet = function() {
var incomplete = [];
for (var i = 0; i < deMembers.length; i++) {
var taskVal = document.getElementById('de_task_' + i).value;
var wpVal = document.getElementById('de_wp_' + i).value;
if (!taskVal || !wpVal) {
incomplete.push(deMembers[i].worker_name);
}
}
if (incomplete.length > 0) {
window.showToast('미입력: ' + incomplete.join(', '), 'error');
return;
}
var sid = deSessionId;
saveDetailEdit().then(function() {
window.completeTbm(sid);
});
};
window.deleteFromDetailSheet = function() {
var sid = deSessionId;
closeDetailEditSheet();
window.deleteTbm(sid);
};
// ─── TBM 완료 바텀시트 ───
var completeSessionId = null;
var completeTeamMembers = [];
window.completeTbm = async function(sid) {
if (isBusy('complete')) return;
setBusy('complete');
showLoading('확인 중...');
completeSessionId = sid;
try {
completeTeamMembers = await window.TbmAPI.getTeamMembers(sid).catch(function() { return []; });
if (completeTeamMembers.length === 0) {
window.showToast('팀원이 없습니다.', 'error');
return;
}
// 세부 미입력 작업자 체크
var incomplete = completeTeamMembers.filter(function(m) { return !m.task_id || !m.workplace_id; });
if (incomplete.length > 0) {
var names = incomplete.map(function(m) { return m.worker_name; }).join(', ');
window.showToast('세부 미입력: ' + names + ' - 세부 내역을 먼저 입력하세요.', 'error');
return;
}
renderCompleteSheet();
document.getElementById('completeOverlay').style.display = 'block';
document.getElementById('completeSheet').style.display = 'block';
} catch(e) {
console.error(e);
window.showToast('팀원 조회 중 오류가 발생했습니다.', 'error');
} finally {
hideLoading();
clearBusy('complete');
}
};
function renderCompleteSheet() {
var html = '';
completeTeamMembers.forEach(function(m, i) {
html += '<div style="padding:0.625rem 0; border-bottom:1px solid #f3f4f6;">' +
'<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.375rem;">' +
'<div><strong style="font-size:0.875rem;">' + esc(m.worker_name) + '</strong> <span style="font-size:0.75rem; color:#6b7280;">(' + esc(m.job_type || '') + ')</span></div>' +
'</div>' +
'<div style="display:flex; gap:0.375rem; align-items:center; flex-wrap:wrap;">' +
'<select id="att_type_' + i + '" onchange="onAttTypeChange(' + i + ')" style="flex:1; min-width:120px; padding:0.5rem; border:1px solid #d1d5db; border-radius:0.375rem; 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>' +
'<input type="number" id="att_hours_' + i + '" placeholder="추가시간" step="0.5" min="0" max="8" style="display:none; width:70px; padding:0.5rem; border:1px solid #d1d5db; border-radius:0.375rem; font-size:0.8125rem; text-align:center;">' +
'<span id="att_hint_' + i + '" style="font-size:0.75rem; color:#6b7280;"></span>' +
'</div>' +
'</div>';
});
document.getElementById('completeWorkerList').innerHTML = html;
}
window.onAttTypeChange = function(idx) {
var sel = document.getElementById('att_type_' + idx);
var inp = document.getElementById('att_hours_' + idx);
var hint = document.getElementById('att_hint_' + idx);
var val = sel.value;
if (val === 'overtime') {
inp.style.display = 'block'; inp.placeholder = '+시간'; inp.value = ''; hint.textContent = '';
} else if (val === 'early') {
inp.style.display = 'block'; inp.placeholder = '근무시간'; inp.value = ''; hint.textContent = '';
} else {
inp.style.display = 'none'; inp.value = '';
var labels = { regular:'8h', annual:'연차 자동처리', half:'4h', quarter:'6h' };
hint.textContent = labels[val] || '';
}
};
window.closeCompleteSheet = function() {
document.getElementById('completeOverlay').style.display = 'none';
document.getElementById('completeSheet').style.display = 'none';
};
window.submitCompleteSheet = async function() {
var attendanceData = [];
for (var i = 0; i < completeTeamMembers.length; i++) {
var type = document.getElementById('att_type_' + i).value;
var hoursVal = document.getElementById('att_hours_' + i).value;
var hours = hoursVal ? parseFloat(hoursVal) : null;
if (type === 'overtime' && (!hours || hours <= 0)) {
window.showToast(esc(completeTeamMembers[i].worker_name) + '의 추가 시간을 입력해주세요.', 'error'); return;
}
if (type === 'early' && (!hours || hours <= 0)) {
window.showToast(esc(completeTeamMembers[i].worker_name) + '의 근무 시간을 입력해주세요.', 'error'); return;
}
attendanceData.push({ user_id: completeTeamMembers[i].user_id, attendance_type: type, attendance_hours: hours });
}
var btn = document.getElementById('completeSheetBtn');
btn.disabled = true; btn.textContent = '처리 중...';
try {
var now = new Date();
var endTime = String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');
var res = await window.apiCall('/tbm/sessions/' + completeSessionId + '/complete', 'POST', {
end_time: endTime, attendance_data: attendanceData
});
if (res && res.success) {
closeCompleteSheet();
window.showToast('TBM이 완료 처리되었습니다.', 'success');
await loadData();
} else {
window.showToast('완료 처리에 실패했습니다.', 'error');
}
} catch(e) {
console.error(e);
window.showToast('오류가 발생했습니다.', 'error');
} finally {
btn.disabled = false; btn.textContent = '완료 처리';
}
};
window.deleteTbm = async function(sid) {
if (!confirm('이 TBM을 삭제하시겠습니까?')) return;
try {
var res = await window.TbmAPI.deleteSession(sid);
if (res && res.success) {
window.showToast('TBM이 삭제되었습니다.', 'success');
await loadData();
} else {
window.showToast('삭제에 실패했습니다.', 'error');
}
} catch(e) {
window.showToast('오류가 발생했습니다.', 'error');
}
};
// ─── 분할 기능 ───
var splitMemberIdx = null;
var splitOption = 'keep'; // 'keep' | 'send'
var splitTargetSessionId = null;
var cachedProjects = null;
var cachedWorkTypes = null;
// 프로젝트/공정 목록 로딩 (캐시)
async function loadProjectsAndWorkTypes() {
if (!cachedProjects) {
try {
cachedProjects = await window.TbmAPI.loadProjects() || [];
} catch(e) { cachedProjects = []; }
}
if (!cachedWorkTypes) {
try {
cachedWorkTypes = await window.TbmAPI.loadWorkTypes() || [];
} catch(e) { cachedWorkTypes = []; }
}
}
function populateProjectSelect(selectId, currentProjectId) {
var sel = document.getElementById(selectId);
var html = '<option value="">원래 프로젝트 유지</option>';
(cachedProjects || []).forEach(function(p) {
html += '<option value="' + p.project_id + '"' + (p.project_id == currentProjectId ? ' selected' : '') + '>' + esc(p.project_name) + '</option>';
});
sel.innerHTML = html;
}
function populateWorkTypeSelect(selectId, currentWorkTypeId) {
var sel = document.getElementById(selectId);
var html = '<option value="">원래 공정 유지</option>';
(cachedWorkTypes || []).forEach(function(wt) {
html += '<option value="' + wt.id + '"' + (wt.id == currentWorkTypeId ? ' selected' : '') + '>' + esc(wt.name) + '</option>';
});
sel.innerHTML = html;
}
window.openSplitSheet = async function(memberIdx) {
if (isBusy('split')) return;
setBusy('split');
showLoading('불러오는 중...');
splitMemberIdx = memberIdx;
splitOption = 'keep';
splitTargetSessionId = null;
var m = deMembers[memberIdx];
var currentHours = m.work_hours === null || m.work_hours === undefined ? 8 : parseFloat(m.work_hours);
document.getElementById('splitTitle').textContent = esc(m.worker_name) + ' 작업 분할';
document.getElementById('splitSubtitle').textContent = '현재 ' + currentHours + 'h 배정';
document.getElementById('splitHours').value = '';
document.getElementById('splitHours').max = currentHours - 0.5;
document.getElementById('splitRemainder').textContent = '';
document.getElementById('splitOptKeep').className = 'split-radio-item active';
document.getElementById('splitOptSend').className = 'split-radio-item';
document.getElementById('splitSessionPicker').style.display = 'none';
// 시간 입력 시 나머지 자동 계산
document.getElementById('splitHours').oninput = function() {
var val = parseFloat(this.value);
if (val && val > 0 && val < currentHours) {
document.getElementById('splitRemainder').textContent = '나머지: ' + (currentHours - val) + 'h';
} else {
document.getElementById('splitRemainder').textContent = '';
}
};
// 프로젝트/공정 목록 로드 + 드롭다운 채우기
await loadProjectsAndWorkTypes();
populateProjectSelect('splitProjectId', null);
populateWorkTypeSelect('splitWorkTypeId', null);
// 다른 세션 목록 로드 (당일)
loadSplitSessionList();
document.getElementById('splitOverlay').style.display = 'block';
document.getElementById('splitSheet').style.display = 'block';
hideLoading();
clearBusy('split');
};
async function loadSplitSessionList() {
var todayStr = getTodayStr();
try {
var sessions = await window.TbmAPI.fetchSessionsByDate(todayStr);
if (sessions && sessions.length > 0) {
var html = '';
sessions.forEach(function(s) {
if (s.session_id === deSessionId) return; // 현재 세션 제외
if (s.status !== 'draft') return; // draft만
var leaderName = s.leader_name || s.created_by_name || '미지정';
var workType = s.work_type_name || '';
html += '<div class="split-session-item" data-sid="' + s.session_id + '" onclick="selectSplitSession(' + s.session_id + ')">' +
esc(leaderName) + (workType ? ' - ' + esc(workType) : '') +
' <span style="font-size:0.6875rem; color:#9ca3af;">(' + (parseInt(s.team_member_count)||0) + '명)</span>' +
'</div>';
});
if (!html) html = '<div style="padding:0.75rem; font-size:0.8125rem; color:#9ca3af; text-align:center;">다른 TBM이 없습니다</div>';
document.getElementById('splitSessionList').innerHTML = html;
}
} catch(e) {
console.error(e);
}
}
window.setSplitOption = function(opt) {
splitOption = opt;
splitTargetSessionId = null;
document.getElementById('splitOptKeep').className = 'split-radio-item' + (opt === 'keep' ? ' active' : '');
document.getElementById('splitOptSend').className = 'split-radio-item' + (opt === 'send' ? ' active' : '');
document.getElementById('splitSessionPicker').style.display = opt === 'send' ? 'block' : 'none';
// 세션 선택 초기화
document.querySelectorAll('.split-session-item').forEach(function(el) { el.classList.remove('active'); });
};
window.selectSplitSession = function(sid) {
splitTargetSessionId = sid;
document.querySelectorAll('.split-session-item').forEach(function(el) {
el.classList.toggle('active', parseInt(el.dataset.sid) === sid);
});
};
window.closeSplitSheet = function() {
document.getElementById('splitOverlay').style.display = 'none';
document.getElementById('splitSheet').style.display = 'none';
clearBusy('split');
};
window.saveSplit = async function() {
var m = deMembers[splitMemberIdx];
var currentHours = m.work_hours === null || m.work_hours === undefined ? 8 : parseFloat(m.work_hours);
var splitHours = parseFloat(document.getElementById('splitHours').value);
if (!splitHours || splitHours <= 0 || splitHours >= currentHours) {
window.showToast('올바른 시간을 입력하세요 (0 < 시간 < ' + currentHours + ')', 'error');
return;
}
var btn = document.getElementById('splitSaveBtn');
btn.disabled = true;
btn.textContent = '처리 중...';
try {
// 프로젝트/공정 선택값
var selProjectId = document.getElementById('splitProjectId').value;
var selWorkTypeId = document.getElementById('splitWorkTypeId').value;
if (splitOption === 'keep') {
var remainHoursKeep = currentHours - splitHours;
var newProjectId = selProjectId ? parseInt(selProjectId) : (m.project_id || null);
var newWorkTypeId = selWorkTypeId ? parseInt(selWorkTypeId) : (m.work_type_id || null);
// 1) 기존 항목: 시간만 줄이기 (프로젝트/공정 유지)
await window.TbmAPI.updateTeamMember(deSessionId, {
user_id: m.user_id,
project_id: m.project_id || null,
work_type_id: m.work_type_id || null,
task_id: m.task_id || null,
workplace_category_id: m.workplace_category_id || null,
workplace_id: m.workplace_id || null,
work_detail: m.work_detail || null,
is_present: true,
work_hours: splitHours
});
// 2) 나머지 시간으로 새 항목 추가 (프로젝트/공정 변경 가능)
await window.TbmAPI.splitAssignment(deSessionId, {
user_id: m.user_id,
work_hours: remainHoursKeep,
project_id: newProjectId,
work_type_id: newWorkTypeId
});
closeSplitSheet();
// 세부 편집 데이터 다시 로드
deMembers = await window.TbmAPI.getTeamMembers(deSessionId).catch(function() { return deMembers; });
renderDetailEditSheet();
window.showToast('분할 완료: ' + splitHours + 'h + ' + remainHoursKeep + 'h', 'success');
} else if (splitOption === 'send') {
if (!splitTargetSessionId) {
window.showToast('이동할 TBM을 선택하세요.', 'error');
btn.disabled = false;
btn.textContent = '분할 저장';
return;
}
var remainHours = currentHours - splitHours;
var destProjectId = selProjectId ? parseInt(selProjectId) : (m.project_id || null);
var destWorkTypeId = selWorkTypeId ? parseInt(selWorkTypeId) : (m.work_type_id || null);
// transfer API 호출
var res = await window.TbmAPI.transfer({
transfer_type: 'send',
user_id: m.user_id,
source_session_id: deSessionId,
dest_session_id: splitTargetSessionId,
hours: remainHours,
project_id: destProjectId,
work_type_id: destWorkTypeId
});
if (res && res.success) {
closeSplitSheet();
closeDetailEditSheet();
window.showToast('이동 완료' + (res.data && res.data.warning ? ' (' + res.data.warning + ')' : ''), 'success');
await loadData();
} else {
window.showToast(res?.message || '이동 실패', 'error');
}
}
} catch(e) {
console.error('분할 오류:', e);
window.showToast('오류가 발생했습니다.', 'error');
} finally {
btn.disabled = false;
btn.textContent = '분할 저장';
}
};
// ─── 빼오기 기능 ───
var pullSessionId = null;
var pullMembers = [];
var pullWorker = null; // 빼오기 대상
var myDraftSession = null; // 내 draft TBM
window.openPullSheet = async function(sid) {
if (isBusy('pull')) return;
setBusy('pull');
showLoading('불러오는 중...');
pullSessionId = sid;
try {
pullMembers = await window.TbmAPI.getTeamMembers(sid).catch(function() { return []; });
var session = await window.TbmAPI.getSession(sid).catch(function() { return null; });
var leaderName = session ? (session.leader_name || session.created_by_name || '미지정') : '미지정';
document.getElementById('pullTitle').textContent = esc(leaderName) + ' 반장 팀';
document.getElementById('pullSubtitle').textContent = pullMembers.length + '명 배정';
// 내 draft TBM 확인
myDraftSession = todaySessions.find(function(s) {
return isMySession(s) && s.status === 'draft';
});
var html = '';
pullMembers.forEach(function(m) {
var hours = m.work_hours === null || m.work_hours === undefined ? 8 : parseFloat(m.work_hours);
var hoursText = hours + 'h';
var btnHtml = '';
if (!myDraftSession) {
btnHtml = '<button type="button" class="pull-btn" disabled title="내 TBM이 없음">내 TBM 없음</button>';
} else {
btnHtml = '<button type="button" class="pull-btn" onclick="event.stopPropagation(); startPull(' + m.user_id + ', \'' + esc(m.worker_name).replace(/'/g, "\\'") + '\', ' + hours + ')">빼오기</button>';
}
html += '<div class="pull-member-item">' +
'<div class="pull-member-info">' +
'<div class="pull-member-name">' + esc(m.worker_name) + ' <span class="m-work-hours-tag">' + hoursText + '</span></div>' +
'<div class="pull-member-sub">' + esc(m.job_type || '') + '</div>' +
'</div>' +
btnHtml +
'</div>';
});
if (pullMembers.length === 0) {
html = '<div style="padding:1.5rem; text-align:center; color:#9ca3af;">팀원이 없습니다</div>';
}
document.getElementById('pullMemberList').innerHTML = html;
document.getElementById('pullOverlay').style.display = 'block';
document.getElementById('pullSheet').style.display = 'block';
} catch(e) {
console.error('빼오기 로드 오류:', e);
window.showToast('데이터를 불러올 수 없습니다.', 'error');
} finally {
hideLoading();
clearBusy('pull');
}
};
window.closePullSheet = function() {
document.getElementById('pullOverlay').style.display = 'none';
document.getElementById('pullSheet').style.display = 'none';
clearBusy('pull');
};
window.startPull = async function(workerId, workerName, maxHours) {
pullWorker = { user_id: workerId, worker_name: workerName, max_hours: maxHours };
document.getElementById('pullHoursTitle').textContent = esc(workerName) + ' 빼오기';
document.getElementById('pullHoursSubtitle').textContent = '최대 ' + maxHours + 'h 가능';
document.getElementById('pullHoursInput').value = maxHours;
document.getElementById('pullHoursInput').max = maxHours;
// 프로젝트/공정 드롭다운 채우기
await loadProjectsAndWorkTypes();
var myProject = myDraftSession ? myDraftSession.project_id : null;
var myWorkType = myDraftSession ? myDraftSession.work_type_id : null;
populateProjectSelect('pullProjectId', myProject);
populateWorkTypeSelect('pullWorkTypeId', myWorkType);
document.getElementById('pullHoursOverlay').style.display = 'block';
document.getElementById('pullHoursSheet').style.display = 'block';
};
window.closePullHoursModal = function() {
document.getElementById('pullHoursOverlay').style.display = 'none';
document.getElementById('pullHoursSheet').style.display = 'none';
};
window.confirmPull = async function() {
var hours = parseFloat(document.getElementById('pullHoursInput').value);
if (!hours || hours <= 0 || hours > pullWorker.max_hours) {
window.showToast('올바른 시간을 입력하세요 (0 < 시간 <= ' + pullWorker.max_hours + ')', 'error');
return;
}
var btn = document.getElementById('pullHoursSaveBtn');
btn.disabled = true;
btn.textContent = '처리 중...';
try {
var pullProjectId = document.getElementById('pullProjectId').value || null;
var pullWorkTypeId = document.getElementById('pullWorkTypeId').value || null;
var res = await window.TbmAPI.transfer({
transfer_type: 'pull',
user_id: pullWorker.user_id,
source_session_id: pullSessionId,
dest_session_id: myDraftSession.session_id,
hours: hours,
project_id: pullProjectId ? parseInt(pullProjectId) : null,
work_type_id: pullWorkTypeId ? parseInt(pullWorkTypeId) : null
});
if (res && res.success) {
closePullHoursModal();
closePullSheet();
window.showToast(esc(pullWorker.worker_name) + ' ' + hours + 'h 빼오기 완료' +
(res.data && res.data.warning ? ' (' + res.data.warning + ')' : ''), 'success');
await loadData();
} else {
window.showToast(res?.message || '빼오기 실패', 'error');
}
} catch(e) {
console.error('빼오기 오류:', e);
window.showToast('오류가 발생했습니다.', 'error');
} finally {
btn.disabled = false;
btn.textContent = '빼오기 실행';
}
};
// ─── 인계 바텀시트 ───
var handoverSessionId = null;
var handoverSession = null;
window.handoverFromDetailSheet = function() {
var sid = deSessionId;
closeDetailEditSheet();
openHandoverSheet(sid);
};
async function openHandoverSheet(sid) {
if (isBusy('handover')) return;
setBusy('handover');
showLoading('인계 정보 불러오는 중...');
handoverSessionId = sid;
try {
var API = window.TbmAPI;
var results = await Promise.all([
API.getSession(sid).catch(function() { return null; }),
API.getTeamMembers(sid).catch(function() { return []; }),
API.loadWorkers().catch(function() { return []; })
]);
handoverSession = results[0];
var team = results[1];
var workers = results[2];
if (!handoverSession) {
window.showToast('세션 정보를 불러올 수 없습니다.', 'error');
return;
}
// 현재 세션 리더를 제외한 반장/그룹장 목록
var leaders = workers.filter(function(w) {
return (w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin') &&
w.user_id !== handoverSession.leader_user_id;
});
var leaderSelect = document.getElementById('handoverLeaderId');
leaderSelect.innerHTML = '<option value="">반장 선택...</option>' +
leaders.map(function(w) {
return '<option value="' + w.user_id + '">' + esc(w.worker_name) + ' (' + (w.job_type || '') + ')</option>';
}).join('');
// 인계할 팀원 체크리스트
var listEl = document.getElementById('handoverWorkerList');
if (team.length === 0) {
listEl.innerHTML = '<p style="padding:1rem; color:#6b7280; text-align:center;">팀원이 없습니다.</p>';
} else {
listEl.innerHTML = team.map(function(m) {
return '<label style="display:flex; align-items:center; gap:0.5rem; padding:0.5rem; cursor:pointer;">' +
'<input type="checkbox" class="handover-worker-cb" value="' + m.user_id + '" checked style="width:16px; height:16px;">' +
'<span style="font-weight:500; font-size:0.875rem;">' + esc(m.worker_name) + '</span>' +
'<span style="font-size:0.75rem; color:#6b7280; margin-left:auto;">' + (m.job_type || '') + '</span>' +
'</label>';
}).join('');
}
document.getElementById('handoverNotes').value = '';
document.getElementById('handoverOverlay').style.display = 'block';
document.getElementById('handoverSheet').style.display = 'block';
} catch(e) {
console.error('인계 시트 열기 오류:', e);
window.showToast('인계 정보를 불러오는 중 오류가 발생했습니다.', 'error');
} finally {
hideLoading();
clearBusy('handover');
}
}
window.openHandoverSheet = openHandoverSheet;
window.closeHandoverSheet = function() {
document.getElementById('handoverOverlay').style.display = 'none';
document.getElementById('handoverSheet').style.display = 'none';
};
window.submitHandover = async function() {
var toLeaderId = parseInt(document.getElementById('handoverLeaderId').value);
var notes = document.getElementById('handoverNotes').value;
if (!toLeaderId) {
window.showToast('인계 대상 반장을 선택해주세요.', 'error');
return;
}
var workerIds = [];
document.querySelectorAll('.handover-worker-cb:checked').forEach(function(cb) {
workerIds.push(parseInt(cb.value));
});
if (workerIds.length === 0) {
window.showToast('인계할 팀원을 최소 1명 선택해주세요.', 'error');
return;
}
var btn = document.querySelector('#handoverSheet .split-btn');
btn.disabled = true;
btn.textContent = '처리 중...';
try {
var today = getTodayStr();
var now = new Date().toTimeString().slice(0, 5);
var handoverData = {
session_id: handoverSessionId,
from_leader_user_id: handoverSession.leader_user_id,
to_leader_user_id: toLeaderId,
handover_date: today,
handover_time: now,
reason: '모바일 인계',
handover_notes: notes,
user_ids: workerIds
};
var res = await window.TbmAPI.saveHandover(handoverData);
if (res && res.success) {
window.closeHandoverSheet();
window.showToast('작업 인계가 요청되었습니다.', 'success');
await loadData();
} else {
window.showToast(res?.message || '인계 요청에 실패했습니다.', 'error');
}
} catch(e) {
console.error('인계 저장 오류:', e);
window.showToast('인계 중 오류가 발생했습니다.', 'error');
} finally {
btn.disabled = false;
btn.textContent = '인계 요청';
}
};
})();