Files
tk-factory-services/system1-factory/web/js/daily-work-report-mobile.js
Hyungi Ahn 4388628788 refactor: TBM/작업보고 코드 통합 및 API 쿼리 버그 수정
- 공통 유틸리티 추출 (common/utils.js, common/base-state.js)
- TBM 모바일 인라인 JS/CSS 외부 파일로 분리 (tbm-mobile.js, tbm-mobile.css)
- 미사용 코드 삭제 (index.js, work-report-*.js 등 5개 파일)
- TBM/작업보고 state.js, utils.js를 공통 모듈 기반으로 전환
- 작업보고서 SSO 인증 호환 수정 (token/user 함수)
- tbmModel.js: incomplete-reports 쿼리에서 users→sso_users 조인 수정, leader_name 조인 추가
- docker-compose.yml: system1-web 볼륨 마운트 추가
- 모바일 인계(handover) 기능 추가

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

1575 lines
62 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 = '<ul>' + details.map(d => '<li>' + esc(d) + '</li>').join('') + '</ul>';
detailsEl.style.display = 'block';
} else if (typeof details === 'string' && details) {
detailsEl.innerHTML = '<p>' + esc(details) + '</p>';
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 = `
<div class="m-confirm-box">
<div class="m-confirm-message">${esc(message)}</div>
<div class="m-confirm-actions">
<button class="m-confirm-cancel" id="mConfirmCancel">취소</button>
<button class="m-confirm-ok ${isDanger ? 'danger' : ''}" id="mConfirmOk">확인</button>
</div>
</div>
`;
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 = '<div class="m-empty"><div class="m-empty-icon">✅</div><div>미완료 TBM 작업이 없습니다</div></div>';
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 += `
<div class="m-date-group ${isExpanded ? 'expanded' : ''}" data-date="${dateStr}">
<div class="m-date-header" onclick="MobileReport.toggleDateGroup('${dateStr}')">
<div class="m-date-left">
<span class="m-date-toggle">▶</span>
<span class="m-date-title">${formatDate(dateData.date)} (${dayOfWeek})</span>
${isToday ? '<span class="m-today-badge">오늘</span>' : ''}
</div>
<div class="m-date-right">
${nonconfCount > 0 ? '<span class="m-issue-type-badge nonconformity">부적합 ' + nonconfCount + '</span>' : ''}
<span>세션${sessions.length} · ${totalWorkers}명</span>
</div>
</div>
<div class="m-date-content">
`;
// 이슈 리마인더
if (issues.length > 0) {
html += `
<div class="m-issue-reminder">
<div class="m-issue-reminder-header">
<span>⚠️</span>
<span>당일 신고 ${issues.length}건</span>
</div>
${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 `
<div class="m-issue-reminder-item">
<span class="m-issue-type-badge ${issue.category_type === 'safety' ? 'safety' : 'nonconformity'}">${issue.category_type === 'safety' ? '안전' : '부적합'}</span>
<span>${esc(issue.issue_category_name || '')} ${esc(desc || '-')}</span>
</div>
`;
}).join('')}
${issues.length > 3 ? '<div style="font-size:0.6875rem;color:#92400e;padding-top:0.25rem;">외 ' + (issues.length - 3) + '건</div>' : ''}
</div>
`;
}
// 세션별 카드
sessions.forEach(group => {
const sessionKey = `${group.session_id}_${dateStr}`;
html += `
<div class="m-session-header">
<span class="m-session-badge">TBM</span>
<span class="m-session-info">작성자: ${esc(group.created_by_name)} · ${group.items.length}명</span>
</div>
`;
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 += `
<button class="m-batch-btn" onclick="MobileReport.batchSubmitSession('${sessionKey}')">
이 세션 일괄 제출 (${group.items.length}건)
</button>
`;
}
});
html += '</div></div>';
});
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 ?
`<span style="display:inline-block; padding:0.125rem 0.375rem; border-radius:0.25rem; font-size:0.625rem; font-weight:700; color:white; background:${getAttendanceBadgeColor(tbm.attendance_type)}; margin-left:0.375rem;">${getAttendanceLabel(tbm.attendance_type)}</span>` : '';
return `
<div class="m-worker-card" data-index="${idx}" data-type="tbm">
<input type="hidden" id="m_totalHours_${idx}" value="${esc(effectiveHours)}">
<div class="m-card-top">
<div>
<div class="m-worker-name">${esc(tbm.worker_name || '작업자')}${attendanceBadge}</div>
<div class="m-worker-job">${esc(tbm.job_type || '-')}</div>
</div>
</div>
<div class="m-card-info">
<div class="m-info-row"><span class="m-info-label">프로젝트</span><span class="m-info-value">${esc(tbm.project_name || '-')}</span></div>
<div class="m-info-row"><span class="m-info-label">공정/작업</span><span class="m-info-value">${esc(tbm.work_type_name || '-')} / ${esc(tbm.task_name || '-')}</span></div>
<div class="m-info-row"><span class="m-info-label">작업장소</span><span class="m-info-value">${esc(tbm.category_name || '')} > ${esc(tbm.workplace_name || '-')}</span></div>
</div>
<div class="m-card-actions">
<div class="m-action-btn ${effectiveHours ? 'has-value' : ''}" onclick="MobileReport.openTimePicker(${idx}, 'tbm')">
<span class="m-action-label">작업시간</span>
<span class="m-action-value" id="m_timeDisplay_${idx}">${timeDisplay}</span>
</div>
<div class="m-action-btn ${defectCount > 0 ? 'has-value' : ''} ${hasRelatedIssue ? 'has-related-issue' : ''}" onclick="MobileReport.openDefectSheet(${idx}, 'tbm')">
<span class="m-action-label">부적합</span>
<span class="m-action-value" id="m_defectDisplay_${idx}">${defectText}</span>
</div>
</div>
<button class="m-submit-btn primary" onclick="MobileReport.submitTbmReport(${idx})">제출</button>
</div>
`;
}
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 += '<div style="font-size:0.8125rem;font-weight:600;color:#374151;margin-bottom:0.5rem;">📋 관련 신고</div>';
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 += `
<div class="m-defect-issue-item ${isSelected ? 'selected' : ''}" data-report-id="${issue.report_id}" onclick="MobileReport.toggleDefectIssue(${issue.report_id})">
<div class="m-defect-checkbox"></div>
<div class="m-defect-issue-info">
<div class="m-defect-issue-name">${esc(issue.issue_category_name || '부적합')}</div>
<div class="m-defect-issue-detail">${esc(desc || '-')} · ${esc(issue.workplace_name || issue.custom_location || '')}</div>
${isSelected ? `
<div class="m-defect-time-input" onclick="event.stopPropagation()">
<label>시간:</label>
<span id="m_defect_issue_time_${issue.report_id}" style="font-weight:600;color:#2563eb;cursor:pointer;" onclick="MobileReport.openDefectTimePicker(${issue.report_id}, 'issue')">${formatHours(defectHours) || '선택'}</span>
</div>
` : ''}
</div>
</div>
`;
});
}
// 수동 부적합 추가 섹션
html += '<div class="m-defect-manual-section">';
html += '<div class="m-defect-manual-title">수동 부적합 추가</div>';
// 기존 수동 부적합 항목 렌더링
const manualDefects = defectSheetTempData.filter(d => !d.issue_report_id);
manualDefects.forEach((defect, i) => {
const realIdx = defectSheetTempData.indexOf(defect);
html += renderManualDefectRow(defect, realIdx);
});
html += `
<button class="m-btn-add" style="width:100%;margin-top:0.5rem;background:#f3f4f6;color:#374151;border:1.5px dashed #d1d5db;" onclick="MobileReport.addManualDefect()">+ 부적합 추가</button>
</div>`;
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 `
<div style="border:1.5px solid #e5e7eb;border-radius:10px;padding:0.625rem;margin-bottom:0.5rem;position:relative;">
<button style="position:absolute;top:0.25rem;right:0.5rem;background:none;border:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;" onclick="MobileReport.removeManualDefect(${idx})">×</button>
<div class="m-form-group" style="margin-bottom:0.5rem;">
<label class="m-form-label">카테고리</label>
<select class="m-form-select" onchange="MobileReport.updateDefectCategory(${idx}, this.value)" style="font-size:16px;">
<option value="">선택</option>
${categories.map(c => `<option value="${c.category_id}" ${defect.category_id == c.category_id ? 'selected' : ''}>${esc(c.category_name)}</option>`).join('')}
</select>
</div>
<div class="m-form-group" style="margin-bottom:0.5rem;">
<label class="m-form-label">항목</label>
<select class="m-form-select" id="m_defectItem_${idx}" onchange="MobileReport.updateDefectItem(${idx}, this.value)" style="font-size:16px;">
<option value="">선택</option>
${filteredItems.map(it => `<option value="${it.item_id}" ${defect.item_id == it.item_id ? 'selected' : ''}>${esc(it.item_name)}</option>`).join('')}
</select>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;">
<span style="font-size:0.75rem;color:#6b7280;">시간:</span>
<span id="m_defect_time_${idx}" style="font-weight:600;color:#2563eb;cursor:pointer;font-size:0.875rem;" onclick="MobileReport.openDefectTimePicker(${idx}, 'manual')">${formatHours(defect.defect_hours) || '선택'}</span>
</div>
<div class="m-form-group" style="margin-top:0.5rem;margin-bottom:0;">
<input class="m-form-input" placeholder="비고 (선택)" value="${esc(defect.note || '')}" onchange="MobileReport.updateDefectNote(${idx}, this.value)" style="font-size:16px;">
</div>
</div>
`;
}
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 = `
<button class="m-manual-delete" onclick="MobileReport.removeManualCard('${id}')">&times;</button>
<div class="m-form-group">
<label class="m-form-label">작업자</label>
<select class="m-form-select" id="m_manual_worker_${id}" onchange="MobileReport.updateManualField('${id}','worker_id',this.value)">
<option value="">작업자 선택</option>
${workers.map(w => `<option value="${w.worker_id}">${esc(w.worker_name)} (${esc(w.job_type || '-')})</option>`).join('')}
</select>
</div>
<div class="m-form-row">
<div class="m-form-group">
<label class="m-form-label">날짜</label>
<input type="date" class="m-form-input" id="m_manual_date_${id}" value="${getKoreaToday()}" onchange="MobileReport.updateManualField('${id}','report_date',this.value)">
</div>
<div class="m-form-group">
<label class="m-form-label">프로젝트</label>
<select class="m-form-select" id="m_manual_project_${id}" onchange="MobileReport.updateManualField('${id}','project_id',this.value)">
<option value="">선택</option>
${projects.map(p => `<option value="${p.project_id}">${esc(p.project_name)}</option>`).join('')}
</select>
</div>
</div>
<div class="m-form-row">
<div class="m-form-group">
<label class="m-form-label">공정</label>
<select class="m-form-select" id="m_manual_workType_${id}" onchange="MobileReport.onWorkTypeChange('${id}', this.value)">
<option value="">선택</option>
${workTypes.map(wt => `<option value="${wt.id}">${esc(wt.name)}</option>`).join('')}
</select>
</div>
<div class="m-form-group">
<label class="m-form-label">작업</label>
<select class="m-form-select" id="m_manual_task_${id}" disabled>
<option value="">공정 먼저 선택</option>
</select>
</div>
</div>
<div class="m-form-group">
<label class="m-form-label">작업장소</label>
<div class="m-workplace-box" id="m_manual_wpBox_${id}" onclick="MobileReport.openWorkplaceSheet('${id}')">
<span class="m-wp-icon">📍</span>
<span class="m-wp-text" id="m_manual_wpText_${id}">클릭하여 선택</span>
</div>
</div>
<div class="m-card-actions">
<div class="m-action-btn" onclick="MobileReport.openTimePicker('${id}', 'manual')">
<span class="m-action-label">작업시간</span>
<span class="m-action-value" id="m_manual_timeDisplay_${id}">선택</span>
<input type="hidden" id="m_manual_hours_${id}" value="">
</div>
<div class="m-action-btn" onclick="MobileReport.openDefectSheet('${id}', 'manual')">
<span class="m-action-label">부적합</span>
<span class="m-action-value" id="m_manual_defectDisplay_${id}">없음</span>
</div>
</div>
<button class="m-submit-btn primary" onclick="MobileReport.submitManualReport('${id}')">제출</button>
`;
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 = `
<div class="m-empty">
<div class="m-empty-icon">📝</div>
<div>수동으로 작업보고서를 추가하세요</div>
<button class="m-btn-add" style="margin-top:0.75rem;" onclick="MobileReport.addManualCard()">+ 수동추가</button>
</div>
`;
}
}
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 = '<option value="">공정 먼저 선택</option>';
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 = '<option value="">선택</option>' +
tasks.map(t => `<option value="${t.task_id}">${esc(t.task_name)}</option>`).join('');
taskSelect.onchange = function() {
updateManualField(id, 'task_id', this.value);
};
} else {
taskSelect.disabled = true;
taskSelect.innerHTML = '<option value="">등록된 작업 없음</option>';
}
} catch (error) {
taskSelect.disabled = true;
taskSelect.innerHTML = '<option value="">로드 실패</option>';
}
}
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 = '<div class="m-loading"></div>';
try {
const response = await window.apiCall('/workplaces/categories');
const categories = response.success ? response.data : response;
let html = '<div class="m-wp-category-grid">';
categories.forEach(cat => {
html += `<button class="m-wp-category-btn" onclick="MobileReport.selectWpCategory(${cat.category_id}, '${esc(cat.category_name)}')">🏭 ${esc(cat.category_name)}</button>`;
});
html += `<button class="m-wp-category-btn" onclick="MobileReport.selectExternalWp()" style="border-color:#0ea5e9;background:#f0f9ff;">🌐 외부</button>`;
html += '</div>';
body.innerHTML = html;
} catch (error) {
body.innerHTML = '<div class="m-empty">작업장소 로드 실패</div>';
}
}
async function selectWpCategory(categoryId, categoryName) {
wpSelectedCategory = categoryId;
wpSelectedCategoryName = categoryName;
wpSheetStep = 'list';
document.getElementById('wpSheetTitle').textContent = categoryName;
const body = document.getElementById('wpSheetBody');
body.innerHTML = '<div class="m-loading"></div>';
try {
const response = await window.apiCall(`/workplaces?category_id=${categoryId}`);
const workplaces = response.success ? response.data : response;
let html = `<button style="display:flex;align-items:center;gap:0.375rem;font-size:0.8125rem;color:#6b7280;background:none;border:none;cursor:pointer;padding:0.375rem 0;margin-bottom:0.5rem;" onclick="MobileReport.renderWorkplaceCategories()">← 뒤로</button>`;
html += '<div class="m-wp-list">';
workplaces.forEach(wp => {
html += `
<div class="m-wp-item" onclick="MobileReport.selectWpItem(${wp.workplace_id}, '${esc(wp.workplace_name)}')">
<span class="m-wp-item-icon">📍</span>
<span class="m-wp-item-name">${esc(wp.workplace_name)}</span>
</div>
`;
});
html += '</div>';
body.innerHTML = html;
} catch (error) {
body.innerHTML = '<div class="m-empty">작업장소 로드 실패</div>';
}
}
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 = '<div class="m-loading"></div>';
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 = '<div class="m-empty"><div class="m-empty-icon">⚠️</div><div>보고서를 불러올 수 없습니다</div></div>';
}
}
function renderCompletedCards(reports) {
const container = document.getElementById('completedCardList');
if (!reports || reports.length === 0) {
container.innerHTML = '<div class="m-empty"><div class="m-empty-icon">📋</div><div>작성된 보고서가 없습니다</div></div>';
return;
}
let html = '';
reports.forEach(report => {
const totalHours = parseFloat(report.total_hours || report.work_hours || 0);
const errorHours = parseFloat(report.error_hours || 0);
html += `
<div class="m-completed-card">
<div class="m-completed-top">
<div>
<div class="m-completed-worker">${esc(report.worker_name || '작업자')}</div>
<span class="m-completed-badge ${report.tbm_session_id ? 'tbm' : 'manual'}">${report.tbm_session_id ? 'TBM' : '수동'}</span>
</div>
</div>
<div class="m-completed-info">
<div class="m-info-row"><span class="m-info-label">프로젝트</span><span class="m-info-value">${esc(report.project_name || '-')}</span></div>
<div class="m-info-row"><span class="m-info-label">공정/작업</span><span class="m-info-value">${esc(report.work_type_name || '-')} / ${esc(report.task_name || '-')}</span></div>
<div class="m-info-row"><span class="m-info-label">작업시간</span><span class="m-info-value">${totalHours}시간</span></div>
${errorHours > 0 ? `<div class="m-info-row"><span class="m-info-label">부적합</span><span class="m-info-value" style="color:#dc2626;">${errorHours}시간 (${esc(report.error_type_name || '-')})</span></div>` : ''}
<div class="m-info-row"><span class="m-info-label">작성자</span><span class="m-info-value">${esc(report.created_by_name || '-')}</span></div>
</div>
<div class="m-completed-actions">
<button class="m-btn-edit" onclick='MobileReport.openEditSheet(${JSON.stringify(report).replace(/'/g, "&#39;")})'>수정</button>
<button class="m-btn-delete" onclick="MobileReport.deleteReport(${report.id})">삭제</button>
</div>
</div>
`;
});
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 = `
<div class="m-form-group">
<label class="m-form-label">작업자</label>
<input class="m-form-input" value="${esc(report.worker_name || '작업자')}" readonly style="background:#f3f4f6;">
</div>
<div class="m-form-group">
<label class="m-form-label">프로젝트</label>
<select class="m-form-select" id="m_edit_project">
${projects.map(p => `<option value="${p.project_id}" ${p.project_id == report.project_id ? 'selected' : ''}>${esc(p.project_name)}</option>`).join('')}
</select>
</div>
<div class="m-form-group">
<label class="m-form-label">공정</label>
<select class="m-form-select" id="m_edit_workType" onchange="MobileReport.loadEditTasks()">
${workTypes.map(wt => `<option value="${wt.id}">${esc(wt.name)}</option>`).join('')}
</select>
</div>
<div class="m-form-group">
<label class="m-form-label">작업</label>
<select class="m-form-select" id="m_edit_task">
<option value="">로딩중...</option>
</select>
</div>
<div class="m-form-group">
<label class="m-form-label">작업시간 (시간)</label>
<input type="number" class="m-form-input" id="m_edit_hours" step="0.5" min="0" max="24" value="${parseFloat(report.work_hours || report.total_hours || 0)}">
</div>
<div class="m-form-group">
<label class="m-form-label">작업상태</label>
<select class="m-form-select" id="m_edit_status">
${workStatusTypes.map(ws => `<option value="${ws.id}" ${ws.id == report.work_status_id ? 'selected' : ''}>${esc(ws.name)}</option>`).join('')}
</select>
</div>
`;
// 작업 목록 로드 후 현재 값 설정
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 = '<option value="">선택</option>' +
tasks.map(t => `<option value="${t.task_id}">${esc(t.task_name)}</option>`).join('');
} catch (error) {
taskSelect.innerHTML = '<option value="">로드 실패</option>';
}
}
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
};
})();