/**
* Daily Work Report - Mobile UI Logic
* 모바일 전용 작업보고서 UI 로직
*
* 재사용 모듈: state.js, api.js, utils.js, api-base.js
*/
const MobileReport = (function() {
'use strict';
// ===== 내부 상태 =====
let currentTab = 'tbm';
let manualCounter = 0;
let manualCards = {}; // { id: { data } }
// 시간 선택 상태
let timePickerCallback = null;
let timePickerValue = 0;
// 부적합 시트 상태
let defectSheetIndex = null;
let defectSheetType = null; // 'tbm' | 'manual'
let defectSheetTempData = []; // 현재 편집중인 부적합 데이터
// 작업장소 시트 상태
let wpSheetManualId = null;
let wpSheetStep = 'category'; // 'category' | 'list'
let wpSelectedCategory = null;
let wpSelectedCategoryName = null;
let wpSelectedId = null;
let wpSelectedName = null;
// 수정 시트 상태
let editReportId = null;
// ===== 초기화 =====
async function init() {
console.log('[Mobile] 초기화 시작');
showMessage('데이터를 불러오는 중...', 'loading');
try {
const api = window.DailyWorkReportAPI;
const state = window.DailyWorkReportState;
await api.loadAllData();
// 부적합 카테고리/아이템 로드 확인
console.log('[Mobile] issueCategories:', (state.issueCategories || []).length, '개');
console.log('[Mobile] issueItems:', (state.issueItems || []).length, '개');
// TBM 데이터 로드
await api.loadIncompleteTbms();
await api.loadDailyIssuesForTbms();
console.log('[Mobile] incompleteTbms:', (state.incompleteTbms || []).length, '개');
console.log('[Mobile] dailyIssuesCache:', Object.keys(state.dailyIssuesCache || {}).length, '날짜');
hideMessage();
renderTbmCards();
// 완료 탭 날짜 초기화
const today = getKoreaToday();
const dateInput = document.getElementById('completedDate');
if (dateInput) dateInput.value = today;
console.log('[Mobile] 초기화 완료');
} catch (error) {
console.error('[Mobile] 초기화 오류:', error);
showMessage('데이터 로드 중 오류가 발생했습니다.', 'error');
}
}
// ===== 유틸리티 (CommonUtils 위임) =====
var CU = window.CommonUtils;
function getKoreaToday() { return CU.getTodayKST(); }
function formatDateForApi(date) { return CU.formatDate(date) || null; }
function formatDate(dateString) {
if (!dateString) return '';
const d = new Date(dateString);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
function getDayOfWeek(dateString) { return CU.getDayOfWeek(dateString); }
function formatHours(val) {
if (!val || val <= 0) return '선택';
if (val === Math.floor(val)) return val + '시간';
const hours = Math.floor(val);
const mins = Math.round((val - hours) * 60);
if (hours === 0) return mins + '분';
return hours + '시간 ' + mins + '분';
}
function esc(str) {
return window.escapeHtml ? window.escapeHtml(String(str || '')) : String(str || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
// ===== 메시지 / 토스트 =====
function showMessage(msg, type) {
const el = document.getElementById('mMessage');
if (!el) return;
el.textContent = msg;
el.className = 'm-message show ' + (type || 'info');
if (type === 'success') setTimeout(hideMessage, 3000);
}
function hideMessage() {
const el = document.getElementById('mMessage');
if (el) el.className = 'm-message';
}
function showToast(msg, type, duration) {
const el = document.getElementById('mToast');
if (!el) return;
el.textContent = msg;
el.className = 'm-toast show ' + (type || '');
clearTimeout(el._timer);
el._timer = setTimeout(() => { el.className = 'm-toast'; }, duration || 2500);
}
function showResult(type, title, message, details) {
const overlay = document.getElementById('mResultOverlay');
const icons = { success: '✅', error: '❌', warning: '⚠️' };
document.getElementById('mResultIcon').textContent = icons[type] || 'ℹ️';
document.getElementById('mResultTitle').textContent = title;
document.getElementById('mResultMessage').textContent = message;
const detailsEl = document.getElementById('mResultDetails');
if (details && Array.isArray(details) && details.length > 0) {
detailsEl.innerHTML = '
' + details.map(d => '' + esc(d) + ' ').join('') + ' ';
detailsEl.style.display = 'block';
} else if (typeof details === 'string' && details) {
detailsEl.innerHTML = '' + esc(details) + '
';
detailsEl.style.display = 'block';
} else {
detailsEl.style.display = 'none';
}
overlay.classList.add('show');
}
function closeResult() {
document.getElementById('mResultOverlay').classList.remove('show');
}
function showConfirm(message, onConfirm, isDanger) {
const overlay = document.createElement('div');
overlay.className = 'm-confirm-overlay';
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
overlay.querySelector('#mConfirmCancel').onclick = () => { overlay.remove(); };
overlay.querySelector('#mConfirmOk').onclick = () => { overlay.remove(); onConfirm(); };
}
// ===== 탭 관리 =====
function switchTab(tab) {
currentTab = tab;
// 탭 버튼 상태
document.querySelectorAll('.m-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
// 탭 콘텐츠
document.querySelectorAll('.m-tab-content').forEach(el => {
el.classList.remove('active');
});
const tabMap = { tbm: 'tabTbm', manual: 'tabManual', completed: 'tabCompleted' };
const targetEl = document.getElementById(tabMap[tab]);
if (targetEl) targetEl.classList.add('active');
// 수동추가 버튼 표시/숨기기
const addBtn = document.getElementById('btnAddManual');
if (addBtn) addBtn.style.display = (tab === 'manual') ? 'block' : 'none';
// 완료 탭 진입 시 자동 조회
if (tab === 'completed') {
loadCompletedReports();
}
}
// ===== TBM 카드 렌더링 =====
function renderTbmCards() {
const state = window.DailyWorkReportState;
const container = document.getElementById('tbmCardList');
const tbms = state.incompleteTbms || [];
// 탭 카운트 업데이트
const countEl = document.getElementById('tbmCount');
if (countEl) countEl.textContent = tbms.length;
if (tbms.length === 0) {
container.innerHTML = '';
return;
}
// 날짜별 그룹화
const byDate = {};
tbms.forEach((tbm, index) => {
const dateStr = formatDateForApi(tbm.session_date);
if (!byDate[dateStr]) {
byDate[dateStr] = { date: tbm.session_date, sessions: {} };
}
const sessionKey = `${tbm.session_id}_${dateStr}`;
if (!byDate[dateStr].sessions[sessionKey]) {
byDate[dateStr].sessions[sessionKey] = {
session_id: tbm.session_id,
created_by_name: tbm.created_by_name,
items: []
};
}
byDate[dateStr].sessions[sessionKey].items.push({ ...tbm, originalIndex: index });
});
const sortedDates = Object.keys(byDate).sort((a, b) => new Date(b) - new Date(a));
const today = getKoreaToday();
let html = '';
sortedDates.forEach((dateStr, dateIndex) => {
const dateData = byDate[dateStr];
const sessions = Object.values(dateData.sessions);
const totalWorkers = sessions.reduce((sum, s) => sum + s.items.length, 0);
const isToday = dateStr === today;
const isExpanded = isToday || dateIndex === 0;
const dayOfWeek = getDayOfWeek(dateStr);
// 당일 신고
const issues = state.dailyIssuesCache[dateStr] || [];
const nonconfCount = issues.filter(i => i.category_type === 'nonconformity').length;
html += `
`;
// 이슈 리마인더
if (issues.length > 0) {
html += `
${issues.slice(0, 3).map(issue => {
const itemText = issue.issue_item_name || '';
const desc = issue.additional_description ? (itemText ? itemText + ' - ' + issue.additional_description : issue.additional_description) : itemText;
return `
${issue.category_type === 'safety' ? '안전' : '부적합'}
${esc(issue.issue_category_name || '')} ${esc(desc || '-')}
`;
}).join('')}
${issues.length > 3 ? '
외 ' + (issues.length - 3) + '건
' : ''}
`;
}
// 세션별 카드
sessions.forEach(group => {
const sessionKey = `${group.session_id}_${dateStr}`;
html += `
`;
group.items.forEach(tbm => {
const idx = tbm.originalIndex;
const defects = state.tempDefects[idx] || [];
const totalDefectHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0);
const totalHoursVal = document.getElementById('m_totalHours_' + idx)?.value || '';
const hasRelatedIssue = issues.some(i => {
if (i.category_type !== 'nonconformity') return false;
if (tbm.workplace_id && i.workplace_id) return tbm.workplace_id === i.workplace_id;
return false;
});
html += renderWorkerCard(tbm, idx, totalHoursVal, defects, totalDefectHours, hasRelatedIssue);
});
// 일괄 제출 버튼
if (group.items.length > 1) {
html += `
이 세션 일괄 제출 (${group.items.length}건)
`;
}
});
html += '
';
});
container.innerHTML = html;
}
function getDefaultHours(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;
}
}
function getAttendanceLabel(type) {
const labels = { overtime: '연장근무', regular: '정시근로', annual: '연차', half: '반차', quarter: '반반차', early: '조퇴' };
return labels[type] || '';
}
function getAttendanceBadgeColor(type) {
const colors = { overtime: '#7c3aed', regular: '#2563eb', annual: '#ef4444', half: '#f59e0b', quarter: '#f97316', early: '#6b7280' };
return colors[type] || '#6b7280';
}
function renderWorkerCard(tbm, idx, totalHoursVal, defects, totalDefectHours, hasRelatedIssue) {
const state = window.DailyWorkReportState;
const defectCount = defects.filter(d => d.defect_hours > 0).length;
const defectText = defectCount > 0 ? totalDefectHours + 'h / ' + defectCount + '건' : '없음';
// 근태 기반 자동 시간 채움
const defaultHours = tbm.attendance_type ? getDefaultHours(tbm) : 0;
const effectiveHours = totalHoursVal || (defaultHours > 0 ? String(defaultHours) : '');
const timeDisplay = effectiveHours ? formatHours(parseFloat(effectiveHours)) : '선택';
const attendanceBadge = tbm.attendance_type ?
`${getAttendanceLabel(tbm.attendance_type)} ` : '';
return `
${esc(tbm.worker_name || '작업자')}${attendanceBadge}
${esc(tbm.job_type || '-')}
프로젝트 ${esc(tbm.project_name || '-')}
공정/작업 ${esc(tbm.work_type_name || '-')} / ${esc(tbm.task_name || '-')}
작업장소 ${esc(tbm.category_name || '')} > ${esc(tbm.workplace_name || '-')}
작업시간
${timeDisplay}
부적합
${defectText}
제출
`;
}
function toggleDateGroup(dateStr) {
const group = document.querySelector(`.m-date-group[data-date="${dateStr}"]`);
if (!group) return;
group.classList.toggle('expanded');
}
// ===== 시간 선택 =====
function openTimePicker(index, type) {
timePickerValue = 0;
const overlay = document.getElementById('mTimeOverlay');
if (type === 'tbm') {
const existing = document.getElementById('m_totalHours_' + index);
if (existing && existing.value) timePickerValue = parseFloat(existing.value) || 0;
document.getElementById('mTimeTitle').textContent = '작업시간 선택';
timePickerCallback = function(val) {
const inp = document.getElementById('m_totalHours_' + index);
if (inp) inp.value = val;
const disp = document.getElementById('m_timeDisplay_' + index);
if (disp) {
disp.textContent = formatHours(val);
disp.closest('.m-action-btn').classList.toggle('has-value', val > 0);
}
};
} else if (type === 'manual') {
const existing = document.getElementById('m_manual_hours_' + index);
if (existing && existing.value) timePickerValue = parseFloat(existing.value) || 0;
document.getElementById('mTimeTitle').textContent = '작업시간 선택';
timePickerCallback = function(val) {
const inp = document.getElementById('m_manual_hours_' + index);
if (inp) inp.value = val;
const disp = document.getElementById('m_manual_timeDisplay_' + index);
if (disp) {
disp.textContent = formatHours(val);
disp.closest('.m-action-btn').classList.toggle('has-value', val > 0);
}
};
} else if (type === 'defect') {
// 부적합 시트 내 시간 선택
timePickerValue = 0;
document.getElementById('mTimeTitle').textContent = '부적합 시간 선택';
timePickerCallback = function(val) {
// index is defect index within defectSheetTempData
if (defectSheetTempData[index]) {
defectSheetTempData[index].defect_hours = val;
const timeEl = document.getElementById('m_defect_time_' + index);
if (timeEl) timeEl.textContent = formatHours(val);
}
};
}
updateTimeDisplay();
highlightTimeButtons();
overlay.classList.add('show');
}
function setTime(val) {
timePickerValue = val;
updateTimeDisplay();
highlightTimeButtons();
}
function adjustTime(delta) {
timePickerValue = Math.max(0, Math.round((timePickerValue + delta) * 10) / 10);
updateTimeDisplay();
highlightTimeButtons();
}
function updateTimeDisplay() {
document.getElementById('mTimeCurrent').textContent = formatHours(timePickerValue) || '0시간';
}
function highlightTimeButtons() {
document.querySelectorAll('.m-time-btn').forEach(btn => {
btn.classList.remove('selected');
});
const quickValues = [0.5, 1, 2, 4, 8];
const btns = document.querySelectorAll('.m-quick-time-grid .m-time-btn');
quickValues.forEach((v, i) => {
if (btns[i] && timePickerValue === v) btns[i].classList.add('selected');
});
}
function confirmTime() {
if (timePickerCallback) {
timePickerCallback(timePickerValue);
}
closeTimePicker();
}
function closeTimePicker() {
document.getElementById('mTimeOverlay').classList.remove('show');
timePickerCallback = null;
}
// ===== 부적합 바텀시트 =====
function openDefectSheet(index, type) {
console.log('[Mobile] openDefectSheet:', index, type);
defectSheetIndex = index;
defectSheetType = type;
const state = window.DailyWorkReportState;
// 기존 부적합 데이터 복사
if (type === 'tbm') {
if (!state.tempDefects[index]) {
state.tempDefects[index] = [];
}
defectSheetTempData = JSON.parse(JSON.stringify(state.tempDefects[index]));
} else {
const mcData = manualCards[index];
if (mcData && mcData.defects) {
defectSheetTempData = JSON.parse(JSON.stringify(mcData.defects));
} else {
defectSheetTempData = [];
}
}
console.log('[Mobile] defectSheetTempData:', defectSheetTempData);
renderDefectSheetContent();
showBottomSheet('defect');
}
function renderDefectSheetContent() {
const state = window.DailyWorkReportState;
const body = document.getElementById('defectSheetBody');
console.log('[Mobile] renderDefectSheetContent, body:', !!body);
console.log('[Mobile] issueCategories:', (state.issueCategories || []).length);
console.log('[Mobile] issueItems:', (state.issueItems || []).length);
// 당일 신고된 이슈 가져오기
let relatedIssues = [];
if (defectSheetType === 'tbm') {
const tbm = state.incompleteTbms[defectSheetIndex];
console.log('[Mobile] tbm:', tbm?.worker_name, 'session_date:', tbm?.session_date, 'workplace_id:', tbm?.workplace_id);
if (tbm) {
const dateStr = formatDateForApi(tbm.session_date);
const allIssues = state.dailyIssuesCache[dateStr] || [];
console.log('[Mobile] allIssues for', dateStr, ':', allIssues.length);
relatedIssues = allIssues.filter(i => {
if (i.category_type !== 'nonconformity') return false;
if (tbm.workplace_id && i.workplace_id) return tbm.workplace_id === i.workplace_id;
if (tbm.workplace_name && (i.workplace_name || i.custom_location)) {
const loc = i.workplace_name || i.custom_location || '';
return loc.includes(tbm.workplace_name) || tbm.workplace_name.includes(loc);
}
return false;
});
}
}
console.log('[Mobile] relatedIssues:', relatedIssues.length);
let html = '';
// 신고 기반 이슈 체크
if (relatedIssues.length > 0) {
html += '📋 관련 신고
';
relatedIssues.forEach(issue => {
const existingIdx = defectSheetTempData.findIndex(d => d.issue_report_id == issue.report_id);
const isSelected = existingIdx >= 0;
const defectHours = isSelected ? defectSheetTempData[existingIdx].defect_hours : 0;
const itemText = issue.issue_item_name || '';
const desc = issue.additional_description ? (itemText ? itemText + ' - ' + issue.additional_description : issue.additional_description) : itemText;
html += `
${esc(issue.issue_category_name || '부적합')}
${esc(desc || '-')} · ${esc(issue.workplace_name || issue.custom_location || '')}
${isSelected ? `
시간:
${formatHours(defectHours) || '선택'}
` : ''}
`;
});
}
// 수동 부적합 추가 섹션
html += '';
html += '
수동 부적합 추가
';
// 기존 수동 부적합 항목 렌더링
const manualDefects = defectSheetTempData.filter(d => !d.issue_report_id);
manualDefects.forEach((defect, i) => {
const realIdx = defectSheetTempData.indexOf(defect);
html += renderManualDefectRow(defect, realIdx);
});
html += `
+ 부적합 추가
`;
body.innerHTML = html;
console.log('[Mobile] defectSheet rendered, html length:', html.length);
}
function renderManualDefectRow(defect, idx) {
const state = window.DailyWorkReportState;
const categories = state.issueCategories || [];
const items = state.issueItems || [];
const filteredItems = defect.category_id ? items.filter(it => it.category_id == defect.category_id) : [];
return `
×
카테고리
선택
${categories.map(c => `${esc(c.category_name)} `).join('')}
항목
선택
${filteredItems.map(it => `${esc(it.item_name)} `).join('')}
시간:
${formatHours(defect.defect_hours) || '선택'}
`;
}
function toggleDefectIssue(reportId) {
const existingIdx = defectSheetTempData.findIndex(d => d.issue_report_id == reportId);
if (existingIdx >= 0) {
// 이미 선택됨 → 해제
defectSheetTempData.splice(existingIdx, 1);
} else {
// 선택 → 추가 (데스크탑과 동일한 구조: 이슈 기반이므로 error_type_id는 null)
defectSheetTempData.push({
issue_report_id: reportId,
error_type_id: null,
defect_hours: 0,
note: ''
});
}
renderDefectSheetContent();
}
function addManualDefect() {
defectSheetTempData.push({
issue_report_id: null,
category_id: null,
item_id: null,
error_type_id: '', // 레거시 호환 (데스크탑과 동일)
defect_hours: 0,
note: ''
});
renderDefectSheetContent();
}
function removeManualDefect(idx) {
defectSheetTempData.splice(idx, 1);
renderDefectSheetContent();
}
function updateDefectCategory(idx, categoryId) {
if (defectSheetTempData[idx]) {
defectSheetTempData[idx].category_id = categoryId ? parseInt(categoryId) : null;
defectSheetTempData[idx].item_id = null;
defectSheetTempData[idx].error_type_id = null;
// 항목 드롭다운 재렌더링
renderDefectSheetContent();
}
}
function updateDefectItem(idx, itemId) {
if (defectSheetTempData[idx]) {
defectSheetTempData[idx].item_id = itemId ? parseInt(itemId) : null;
defectSheetTempData[idx].error_type_id = itemId ? parseInt(itemId) : null;
}
}
function updateDefectNote(idx, note) {
if (defectSheetTempData[idx]) {
defectSheetTempData[idx].note = note;
}
}
function openDefectTimePicker(id, type) {
if (type === 'issue') {
// issue_report_id 기반
const idx = defectSheetTempData.findIndex(d => d.issue_report_id == id);
if (idx < 0) return;
timePickerValue = defectSheetTempData[idx].defect_hours || 0;
timePickerCallback = function(val) {
defectSheetTempData[idx].defect_hours = val;
const el = document.getElementById('m_defect_issue_time_' + id);
if (el) el.textContent = formatHours(val);
};
} else {
// manual index
timePickerValue = defectSheetTempData[id]?.defect_hours || 0;
timePickerCallback = function(val) {
if (defectSheetTempData[id]) {
defectSheetTempData[id].defect_hours = val;
const el = document.getElementById('m_defect_time_' + id);
if (el) el.textContent = formatHours(val);
}
};
}
updateTimeDisplay();
highlightTimeButtons();
document.getElementById('mTimeTitle').textContent = '부적합 시간 선택';
document.getElementById('mTimeOverlay').classList.add('show');
}
function saveDefects() {
const state = window.DailyWorkReportState;
// 수동 부적합 유효성 검사: category_id 또는 item_id가 있어야 저장
const manualDefects = defectSheetTempData.filter(d => !d.issue_report_id);
const invalidManual = manualDefects.filter(d => d.defect_hours > 0 && !d.category_id && !d.item_id);
if (invalidManual.length > 0) {
showToast('부적합 카테고리/항목을 선택해주세요', 'error');
return;
}
// _saved 플래그 설정 (데스크탑 호환)
defectSheetTempData.forEach(d => { d._saved = true; });
if (defectSheetType === 'tbm') {
state.tempDefects[defectSheetIndex] = defectSheetTempData;
// 카드 UI 업데이트
const defects = defectSheetTempData;
const totalDefectHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0);
const defectCount = defects.filter(d => d.defect_hours > 0).length;
const defectText = defectCount > 0 ? totalDefectHours + 'h / ' + defectCount + '건' : '없음';
const disp = document.getElementById('m_defectDisplay_' + defectSheetIndex);
if (disp) {
disp.textContent = defectText;
disp.closest('.m-action-btn').classList.toggle('has-value', defectCount > 0);
}
} else if (defectSheetType === 'manual') {
if (manualCards[defectSheetIndex]) {
manualCards[defectSheetIndex].defects = defectSheetTempData;
// UI 업데이트
const defects = defectSheetTempData;
const totalDefectHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0);
const defectCount = defects.filter(d => d.defect_hours > 0).length;
const defectText = defectCount > 0 ? totalDefectHours + 'h / ' + defectCount + '건' : '없음';
const disp = document.getElementById('m_manual_defectDisplay_' + defectSheetIndex);
if (disp) {
disp.textContent = defectText;
disp.closest('.m-action-btn').classList.toggle('has-value', defectCount > 0);
}
}
}
hideDefectSheet();
showToast('부적합 정보가 저장되었습니다', 'success');
}
function hideDefectSheet() {
hideBottomSheet('defect');
defectSheetIndex = null;
defectSheetType = null;
}
// ===== 제출 =====
async function submitTbmReport(index) {
const state = window.DailyWorkReportState;
const tbm = state.incompleteTbms[index];
if (!tbm) return;
const totalHours = parseFloat(document.getElementById('m_totalHours_' + index)?.value);
const defects = state.tempDefects[index] || [];
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
// 검증
if (!totalHours || totalHours <= 0) {
showToast('작업시간을 선택해주세요', 'error');
return;
}
if (errorHours > totalHours) {
showToast('부적합 시간이 총 작업시간을 초과합니다', 'error');
return;
}
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id && !d.item_id);
if (invalidDefects.length > 0) {
showToast('부적합 원인을 선택해주세요', 'error');
return;
}
const reportDate = typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
? tbm.session_date.split('T')[0]
: (tbm.session_date instanceof Date ? formatDateForApi(tbm.session_date) : tbm.session_date);
const reportData = {
tbm_assignment_id: tbm.assignment_id,
tbm_session_id: tbm.session_id,
user_id: tbm.user_id,
project_id: tbm.project_id,
work_type_id: tbm.task_id,
report_date: reportDate,
start_time: null,
end_time: null,
total_hours: totalHours,
error_hours: errorHours,
error_type_id: errorTypeId,
work_status_id: errorHours > 0 ? 2 : 1
};
// 제출 버튼 비활성화
const card = document.querySelector(`.m-worker-card[data-index="${index}"]`);
const btn = card?.querySelector('.m-submit-btn');
if (btn) { btn.disabled = true; btn.textContent = '제출 중...'; }
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
if (!response.success) throw new Error(response.message || '제출 실패');
// 부적합 원인 저장
if (defects.length > 0 && response.data?.report_id) {
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
if (validDefects.length > 0) {
const defectsToSend = validDefects.map(d => ({
issue_report_id: d.issue_report_id || null,
category_id: d.category_id || null,
item_id: d.item_id || null,
error_type_id: d.error_type_id || null,
defect_hours: d.defect_hours,
note: d.note || ''
}));
await window.apiCall(`/daily-work-reports/${response.data.report_id}/defects`, 'PUT', { defects: defectsToSend });
}
}
// 임시 데이터 삭제
delete state.tempDefects[index];
showResult('success', '제출 완료', `${tbm.worker_name}의 작업보고서가 제출되었습니다.`,
response.data.tbm_completed ? '모든 팀원의 작업보고서가 제출되어 TBM이 완료되었습니다.' : response.data.completion_status
);
// 목록 새로고침
await refreshTbmList();
} catch (error) {
console.error('[Mobile] TBM 제출 오류:', error);
showResult('error', '제출 실패', error.message);
if (btn) { btn.disabled = false; btn.textContent = '제출'; }
}
}
async function batchSubmitSession(sessionKey) {
const state = window.DailyWorkReportState;
const cards = document.querySelectorAll(`.m-worker-card[data-type="tbm"]`);
// 세션에 해당하는 카드 찾기
const tbms = state.incompleteTbms || [];
const byDate = {};
tbms.forEach((tbm, index) => {
const dateStr = formatDateForApi(tbm.session_date);
const key = `${tbm.session_id}_${dateStr}`;
if (!byDate[key]) byDate[key] = [];
byDate[key].push({ tbm, index });
});
const sessionItems = byDate[sessionKey];
if (!sessionItems || sessionItems.length === 0) {
showToast('제출할 항목이 없습니다', 'error');
return;
}
// 검증
const errors = [];
const itemsToSubmit = [];
sessionItems.forEach(({ tbm, index }) => {
const totalHours = parseFloat(document.getElementById('m_totalHours_' + index)?.value);
const defects = state.tempDefects[index] || [];
const errorHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0);
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
if (!totalHours || totalHours <= 0) {
errors.push(`${tbm.worker_name}: 작업시간 미입력`);
return;
}
if (errorHours > totalHours) {
errors.push(`${tbm.worker_name}: 부적합 시간 초과`);
return;
}
const reportDate = typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
? tbm.session_date.split('T')[0]
: (tbm.session_date instanceof Date ? formatDateForApi(tbm.session_date) : tbm.session_date);
itemsToSubmit.push({
index,
tbm,
defects,
data: {
tbm_assignment_id: tbm.assignment_id,
tbm_session_id: tbm.session_id,
user_id: tbm.user_id,
project_id: tbm.project_id,
work_type_id: tbm.task_id,
report_date: reportDate,
start_time: null,
end_time: null,
total_hours: totalHours,
error_hours: errorHours,
error_type_id: errorTypeId,
work_status_id: errorHours > 0 ? 2 : 1
}
});
});
if (errors.length > 0) {
showResult('error', '일괄제출 검증 실패', '모든 항목이 유효해야 제출할 수 있습니다.', errors);
return;
}
showMessage('일괄 제출 중...', 'loading');
const results = { success: [], failed: [] };
for (const item of itemsToSubmit) {
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', item.data);
if (response.success) {
// 부적합 저장
if (item.defects.length > 0 && response.data?.report_id) {
const validDefects = item.defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
if (validDefects.length > 0) {
const defectsToSend = validDefects.map(d => ({
issue_report_id: d.issue_report_id || null,
category_id: d.category_id || null,
item_id: d.item_id || null,
error_type_id: d.error_type_id || null,
defect_hours: d.defect_hours,
note: d.note || ''
}));
await window.apiCall(`/daily-work-reports/${response.data.report_id}/defects`, 'PUT', { defects: defectsToSend });
}
}
delete state.tempDefects[item.index];
results.success.push(item.tbm.worker_name);
} else {
results.failed.push(`${item.tbm.worker_name}: ${response.message}`);
}
} catch (error) {
results.failed.push(`${item.tbm.worker_name}: ${error.message}`);
}
}
hideMessage();
if (results.failed.length === 0) {
showResult('success', '일괄제출 완료', `${itemsToSubmit.length}건 모두 성공`, results.success.map(n => '✓ ' + n));
} else if (results.success.length === 0) {
showResult('error', '일괄제출 실패', `${itemsToSubmit.length}건 모두 실패`, results.failed);
} else {
showResult('warning', '일괄제출 부분 완료', `성공 ${results.success.length}건 / 실패 ${results.failed.length}건`,
[...results.success.map(n => '✓ ' + n), ...results.failed.map(m => '✗ ' + m)]
);
}
await refreshTbmList();
}
async function refreshTbmList() {
const api = window.DailyWorkReportAPI;
await api.loadIncompleteTbms();
await api.loadDailyIssuesForTbms();
renderTbmCards();
}
// ===== 수동 입력 =====
function addManualCard() {
const id = 'mc_' + (manualCounter++);
const state = window.DailyWorkReportState;
manualCards[id] = {
user_id: null,
report_date: getKoreaToday(),
project_id: null,
work_type_id: null,
task_id: null,
workplace_category_id: null,
workplace_id: null,
workplace_name: null,
workplace_category_name: null,
total_hours: 0,
defects: []
};
const container = document.getElementById('manualCardList');
// 빈 상태 메시지 제거
const empty = container.querySelector('.m-empty');
if (empty) empty.remove();
const cardEl = document.createElement('div');
cardEl.className = 'm-manual-card';
cardEl.id = 'm_manual_card_' + id;
cardEl.setAttribute('data-manual-id', id);
cardEl.style.position = 'relative';
const workers = state.workers || [];
const projects = state.projects || [];
const workTypes = state.workTypes || [];
cardEl.innerHTML = `
×
작업자
작업자 선택
${workers.map(w => `${esc(w.worker_name)} (${esc(w.job_type || '-')}) `).join('')}
제출
`;
container.appendChild(cardEl);
// 수동 입력 탭으로 이동
if (currentTab !== 'manual') {
switchTab('manual');
}
}
function removeManualCard(id) {
const card = document.getElementById('m_manual_card_' + id);
if (card) card.remove();
delete manualCards[id];
// 카드가 모두 없으면 빈 상태 표시
if (Object.keys(manualCards).length === 0) {
document.getElementById('manualCardList').innerHTML = `
📝
수동으로 작업보고서를 추가하세요
+ 수동추가
`;
}
}
function updateManualField(id, field, value) {
if (manualCards[id]) {
manualCards[id][field] = value;
}
}
async function onWorkTypeChange(id, workTypeId) {
updateManualField(id, 'work_type_id', workTypeId);
updateManualField(id, 'task_id', null);
const taskSelect = document.getElementById('m_manual_task_' + id);
if (!workTypeId) {
taskSelect.disabled = true;
taskSelect.innerHTML = '공정 먼저 선택 ';
return;
}
try {
const response = await window.apiCall(`/tasks?work_type_id=${workTypeId}`);
const tasks = response.success ? response.data : (Array.isArray(response) ? response : []);
if (tasks && tasks.length > 0) {
taskSelect.disabled = false;
taskSelect.innerHTML = '선택 ' +
tasks.map(t => `${esc(t.task_name)} `).join('');
taskSelect.onchange = function() {
updateManualField(id, 'task_id', this.value);
};
} else {
taskSelect.disabled = true;
taskSelect.innerHTML = '등록된 작업 없음 ';
}
} catch (error) {
taskSelect.disabled = true;
taskSelect.innerHTML = '로드 실패 ';
}
}
async function submitManualReport(id) {
const mc = manualCards[id];
if (!mc) return;
const workerId = mc.user_id || document.getElementById('m_manual_worker_' + id)?.value;
const reportDate = mc.report_date || document.getElementById('m_manual_date_' + id)?.value;
const projectId = mc.project_id || document.getElementById('m_manual_project_' + id)?.value;
const taskId = mc.task_id || document.getElementById('m_manual_task_' + id)?.value;
const totalHours = parseFloat(document.getElementById('m_manual_hours_' + id)?.value);
const workplaceId = mc.workplace_id;
const defects = mc.defects || [];
const errorHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0);
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
// 검증
if (!workerId) { showToast('작업자를 선택해주세요', 'error'); return; }
if (!reportDate) { showToast('날짜를 입력해주세요', 'error'); return; }
if (!projectId) { showToast('프로젝트를 선택해주세요', 'error'); return; }
if (!taskId) { showToast('작업을 선택해주세요', 'error'); return; }
if (workplaceId === null || workplaceId === undefined) { showToast('작업장소를 선택해주세요', 'error'); return; }
if (!totalHours || totalHours <= 0) { showToast('작업시간을 선택해주세요', 'error'); return; }
if (errorHours > totalHours) { showToast('부적합 시간이 초과합니다', 'error'); return; }
const reportData = {
report_date: reportDate,
user_id: parseInt(workerId),
work_entries: [{
project_id: parseInt(projectId),
task_id: parseInt(taskId),
work_hours: totalHours,
work_status_id: errorHours > 0 ? 2 : 1,
error_type_id: errorTypeId ? parseInt(errorTypeId) : null
}]
};
const btn = document.querySelector(`#m_manual_card_${id} .m-submit-btn`);
if (btn) { btn.disabled = true; btn.textContent = '제출 중...'; }
try {
let response;
let retries = 3;
for (let i = 0; i < retries; i++) {
try {
response = await window.apiCall('/daily-work-reports', 'POST', reportData);
break;
} catch (err) {
if ((err.message?.includes('429') || err.message?.includes('너무 많은 요청')) && i < retries - 1) {
await new Promise(r => setTimeout(r, (i + 1) * 2000));
continue;
}
throw err;
}
}
if (!response.success) throw new Error(response.message || '제출 실패');
// 부적합 저장
const reportId = response.data?.inserted_ids?.[0] || response.data?.workReport_ids?.[0];
if (defects.length > 0 && reportId) {
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
if (validDefects.length > 0) {
await window.apiCall(`/daily-work-reports/${reportId}/defects`, 'PUT', { defects: validDefects });
}
}
removeManualCard(id);
showToast('작업보고서가 제출되었습니다', 'success');
} catch (error) {
console.error('[Mobile] 수동 제출 오류:', error);
showToast('제출 실패: ' + error.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = '제출'; }
}
}
// ===== 작업장소 바텀시트 =====
function openWorkplaceSheet(manualId) {
wpSheetManualId = manualId;
wpSheetStep = 'category';
wpSelectedCategory = null;
wpSelectedCategoryName = null;
wpSelectedId = null;
wpSelectedName = null;
renderWorkplaceCategories();
showBottomSheet('wp');
}
async function renderWorkplaceCategories() {
const body = document.getElementById('wpSheetBody');
document.getElementById('wpSheetTitle').textContent = '작업장소 선택';
body.innerHTML = '
';
try {
const response = await window.apiCall('/workplaces/categories');
const categories = response.success ? response.data : response;
let html = '';
categories.forEach(cat => {
html += `🏭 ${esc(cat.category_name)} `;
});
html += `🌐 외부 `;
html += '
';
body.innerHTML = html;
} catch (error) {
body.innerHTML = '작업장소 로드 실패
';
}
}
async function selectWpCategory(categoryId, categoryName) {
wpSelectedCategory = categoryId;
wpSelectedCategoryName = categoryName;
wpSheetStep = 'list';
document.getElementById('wpSheetTitle').textContent = categoryName;
const body = document.getElementById('wpSheetBody');
body.innerHTML = '
';
try {
const response = await window.apiCall(`/workplaces?category_id=${categoryId}`);
const workplaces = response.success ? response.data : response;
let html = `← 뒤로 `;
html += '';
workplaces.forEach(wp => {
html += `
📍
${esc(wp.workplace_name)}
`;
});
html += '
';
body.innerHTML = html;
} catch (error) {
body.innerHTML = '작업장소 로드 실패
';
}
}
function selectWpItem(workplaceId, workplaceName) {
wpSelectedId = workplaceId;
wpSelectedName = workplaceName;
// 수동 카드에 반영
if (manualCards[wpSheetManualId]) {
manualCards[wpSheetManualId].workplace_id = workplaceId;
manualCards[wpSheetManualId].workplace_name = workplaceName;
manualCards[wpSheetManualId].workplace_category_id = wpSelectedCategory;
manualCards[wpSheetManualId].workplace_category_name = wpSelectedCategoryName;
}
const textEl = document.getElementById('m_manual_wpText_' + wpSheetManualId);
const boxEl = document.getElementById('m_manual_wpBox_' + wpSheetManualId);
if (textEl) textEl.textContent = `${wpSelectedCategoryName} > ${workplaceName}`;
if (boxEl) boxEl.classList.add('selected');
hideWorkplaceSheet();
showToast('작업장소 선택됨', 'success');
}
function selectExternalWp() {
if (manualCards[wpSheetManualId]) {
manualCards[wpSheetManualId].workplace_id = 0;
manualCards[wpSheetManualId].workplace_name = '외부 (외근/연차/휴무)';
manualCards[wpSheetManualId].workplace_category_id = 0;
manualCards[wpSheetManualId].workplace_category_name = '외부';
}
const textEl = document.getElementById('m_manual_wpText_' + wpSheetManualId);
const boxEl = document.getElementById('m_manual_wpBox_' + wpSheetManualId);
if (textEl) textEl.textContent = '🌐 외부 (외근/연차/휴무)';
if (boxEl) boxEl.classList.add('selected');
hideWorkplaceSheet();
showToast('외부 작업장소 선택됨', 'success');
}
function hideWorkplaceSheet() {
hideBottomSheet('wp');
}
// ===== 완료 보고서 =====
async function loadCompletedReports() {
const dateInput = document.getElementById('completedDate');
const selectedDate = dateInput?.value;
if (!selectedDate) return;
const container = document.getElementById('completedCardList');
container.innerHTML = '
';
try {
const response = await window.apiCall(`/daily-work-reports?date=${selectedDate}`);
let reports = [];
if (Array.isArray(response)) {
reports = response;
} else if (response.success && response.data) {
reports = Array.isArray(response.data) ? response.data : [];
} else if (response.data) {
reports = Array.isArray(response.data) ? response.data : [];
}
renderCompletedCards(reports);
} catch (error) {
console.error('[Mobile] 완료 보고서 로드 오류:', error);
container.innerHTML = '';
}
}
function renderCompletedCards(reports) {
const container = document.getElementById('completedCardList');
if (!reports || reports.length === 0) {
container.innerHTML = '';
return;
}
let html = '';
reports.forEach(report => {
const totalHours = parseFloat(report.total_hours || report.work_hours || 0);
const errorHours = parseFloat(report.error_hours || 0);
html += `
${esc(report.worker_name || '작업자')}
${report.tbm_session_id ? 'TBM' : '수동'}
프로젝트 ${esc(report.project_name || '-')}
공정/작업 ${esc(report.work_type_name || '-')} / ${esc(report.task_name || '-')}
작업시간 ${totalHours}시간
${errorHours > 0 ? `
부적합 ${errorHours}시간 (${esc(report.error_type_name || '-')})
` : ''}
작성자 ${esc(report.created_by_name || '-')}
수정
삭제
`;
});
container.innerHTML = html;
}
// ===== 수정 바텀시트 =====
function openEditSheet(report) {
editReportId = report.id;
const state = window.DailyWorkReportState;
const projects = state.projects || [];
const workTypes = state.workTypes || [];
const workStatusTypes = state.workStatusTypes || [];
const body = document.getElementById('editSheetBody');
body.innerHTML = `
작업자
프로젝트
${projects.map(p => `${esc(p.project_name)} `).join('')}
공정
${workTypes.map(wt => `${esc(wt.name)} `).join('')}
작업
로딩중...
작업시간 (시간)
작업상태
${workStatusTypes.map(ws => `${esc(ws.name)} `).join('')}
`;
// 작업 목록 로드 후 현재 값 설정
loadEditTasks().then(() => {
const taskSelect = document.getElementById('m_edit_task');
if (report.work_type_id && taskSelect) {
taskSelect.value = report.work_type_id;
}
});
showBottomSheet('edit');
}
async function loadEditTasks() {
const workTypeId = document.getElementById('m_edit_workType')?.value;
const taskSelect = document.getElementById('m_edit_task');
if (!workTypeId || !taskSelect) return;
try {
const response = await window.apiCall(`/tasks?work_type_id=${workTypeId}`);
const tasks = response.data || response || [];
taskSelect.innerHTML = '선택 ' +
tasks.map(t => `${esc(t.task_name)} `).join('');
} catch (error) {
taskSelect.innerHTML = '로드 실패 ';
}
}
async function saveEditedReport() {
const projectId = document.getElementById('m_edit_project')?.value;
const taskId = document.getElementById('m_edit_task')?.value;
const workHours = parseFloat(document.getElementById('m_edit_hours')?.value);
const workStatusId = document.getElementById('m_edit_status')?.value;
if (!projectId || !taskId || !workHours) {
showToast('필수 항목을 입력해주세요', 'error');
return;
}
try {
const response = await window.apiCall(`/daily-work-reports/${editReportId}`, 'PUT', {
project_id: parseInt(projectId),
work_type_id: parseInt(taskId),
work_hours: workHours,
work_status_id: parseInt(workStatusId)
});
if (response.success) {
hideEditSheet();
showToast('수정 완료', 'success');
loadCompletedReports();
} else {
throw new Error(response.message || '수정 실패');
}
} catch (error) {
showToast('수정 실패: ' + error.message, 'error');
}
}
function hideEditSheet() {
hideBottomSheet('edit');
editReportId = null;
}
// ===== 삭제 =====
function deleteReport(reportId) {
showConfirm('이 작업보고서를 삭제하시겠습니까?', async () => {
try {
const response = await window.apiCall(`/daily-work-reports/${reportId}`, 'DELETE');
if (response.success) {
showToast('삭제 완료', 'success');
loadCompletedReports();
} else {
throw new Error(response.message || '삭제 실패');
}
} catch (error) {
showToast('삭제 실패: ' + error.message, 'error');
}
}, true);
}
// ===== 바텀시트 공통 =====
function showBottomSheet(id) {
const sheetMap = { defect: 'defectSheet', wp: 'wpSheet', edit: 'editSheet' };
const overlayMap = { defect: 'defectOverlay', wp: 'wpOverlay', edit: 'editOverlay' };
const sheet = document.getElementById(sheetMap[id]);
const overlay = document.getElementById(overlayMap[id]);
if (overlay) overlay.classList.add('show');
if (sheet) {
sheet.classList.add('show');
// body 스크롤 방지
document.body.style.overflow = 'hidden';
}
}
function hideBottomSheet(id) {
const sheetMap = { defect: 'defectSheet', wp: 'wpSheet', edit: 'editSheet' };
const overlayMap = { defect: 'defectOverlay', wp: 'wpOverlay', edit: 'editOverlay' };
const sheet = document.getElementById(sheetMap[id]);
const overlay = document.getElementById(overlayMap[id]);
if (overlay) overlay.classList.remove('show');
if (sheet) sheet.classList.remove('show');
document.body.style.overflow = '';
}
// ===== DOM Ready =====
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => { setTimeout(init, 100); });
} else {
setTimeout(init, 100);
}
// ===== Public API =====
return {
switchTab,
toggleDateGroup,
openTimePicker,
setTime,
adjustTime,
confirmTime,
closeTimePicker,
openDefectSheet,
toggleDefectIssue,
addManualDefect,
removeManualDefect,
updateDefectCategory,
updateDefectItem,
updateDefectNote,
openDefectTimePicker,
saveDefects,
hideDefectSheet,
submitTbmReport,
batchSubmitSession,
addManualCard,
removeManualCard,
updateManualField,
onWorkTypeChange,
submitManualReport,
openWorkplaceSheet,
renderWorkplaceCategories,
selectWpCategory,
selectWpItem,
selectExternalWp,
hideWorkplaceSheet,
loadCompletedReports,
openEditSheet,
loadEditTasks,
saveEditedReport,
hideEditSheet,
deleteReport,
closeResult,
showToast
};
})();