/** * 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 = ''; 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 = `
${esc(message)}
`; 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 = '
미완료 TBM 작업이 없습니다
'; 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 += `
${formatDate(dateData.date)} (${dayOfWeek}) ${isToday ? '오늘' : ''}
${nonconfCount > 0 ? '부적합 ' + nonconfCount + '' : ''} 세션${sessions.length} · ${totalWorkers}명
`; // 이슈 리마인더 if (issues.length > 0) { html += `
⚠️ 당일 신고 ${issues.length}건
${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 += `
TBM 작성자: ${esc(group.created_by_name)} · ${group.items.length}명
`; 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 += ` `; } }); 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}
`; } 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 `
시간: ${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, worker_id: tbm.worker_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, worker_id: tbm.worker_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] = { worker_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 = `
📍 클릭하여 선택
작업시간 선택
부적합 없음
`; 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 => ``).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.worker_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, worker_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 += ``; }); 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 = `
`; // 작업 목록 로드 후 현재 값 설정 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 => ``).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 }; })();