feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등

- 순찰/점검 기능 개선 (zone-detail 페이지 추가)
- 출근/근태 시스템 개선 (연차 조회, 근무현황)
- 작업분석 대분류 그룹화 및 마이그레이션 스크립트
- 모바일 네비게이션 UI 추가
- NAS 배포 도구 및 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:41:01 +09:00
parent 1548253f56
commit 2b1c7bfb88
633 changed files with 361224 additions and 1090 deletions

View File

@@ -267,9 +267,14 @@ function renderTbmWorkList() {
// 수동 입력 섹션 먼저 추가 (맨 위)
html += `
<div class="tbm-session-group manual-input-section">
<div class="tbm-session-header" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
<span class="tbm-session-badge" style="background-color: #92400e; color: white;">수동 입력</span>
<span class="tbm-session-info" style="color: white; font-weight: 500;">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
<div class="tbm-session-header" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<div>
<span class="tbm-session-badge" style="background-color: #92400e; color: white;">수동 입력</span>
<span class="tbm-session-info" style="color: white; font-weight: 500;">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
</div>
<button type="button" class="btn-batch-submit" onclick="submitAllManualWorkReports()" style="background: #fff; color: #d97706; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; font-weight: 600; cursor: pointer; font-size: 0.8rem;">
📤 일괄 제출
</button>
</div>
<div class="tbm-table-container">
<table class="tbm-work-table">
@@ -550,7 +555,8 @@ window.submitTbmWorkReport = async function(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].error_type_id : null;
// item_id를 error_type_id로 사용 (issue_report_items.item_id)
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
// 필수 필드 검증
if (!totalHours || totalHours <= 0) {
@@ -573,7 +579,7 @@ window.submitTbmWorkReport = async function(index) {
_saved: d._saved
})));
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id);
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) {
console.error('❌ 유효하지 않은 부적합:', invalidDefects);
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
@@ -592,7 +598,7 @@ window.submitTbmWorkReport = async function(index) {
tbm_session_id: tbm.session_id,
worker_id: tbm.worker_id,
project_id: tbm.project_id,
work_type_id: tbm.work_type_id,
work_type_id: tbm.task_id, // task_id를 work_type_id 컬럼에 저장 (직접 작업보고서와 일관성 유지)
report_date: reportDate,
start_time: null,
end_time: null,
@@ -614,7 +620,7 @@ window.submitTbmWorkReport = async function(index) {
// 부적합 원인이 있으면 저장 (이슈 기반 또는 레거시)
if (defects.length > 0 && response.data?.report_id) {
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.error_type_id) && d.defect_hours > 0);
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0);
console.log('📋 부적합 원인 필터링:', {
전체: defects.length,
유효: validDefects.length,
@@ -729,7 +735,7 @@ window.batchSubmitTbmSession = async function(sessionKey) {
tbm_session_id: tbm.session_id,
worker_id: tbm.worker_id,
project_id: tbm.project_id,
work_type_id: tbm.work_type_id,
work_type_id: tbm.task_id, // task_id를 work_type_id 컬럼에 저장 (일관성 유지)
report_date: reportDate,
start_time: null,
end_time: null,
@@ -1032,7 +1038,12 @@ window.openWorkplaceMapForManual = async function(manualIndex) {
${safeName}
</button>
`;
}).join('');
}).join('') + `
<button type="button" class="btn btn-secondary" style="width: 100%; text-align: left; margin-top: 0.5rem; background-color: #f0f9ff; border-color: #0ea5e9;" onclick='selectExternalWorkplace()'>
<span style="margin-right: 0.5rem;">🌐</span>
외부 (외근/연차/휴무 등)
</button>
`;
// 카테고리 선택 화면 표시
document.getElementById('categorySelectionArea').style.display = 'block';
@@ -1307,6 +1318,43 @@ window.closeWorkplaceModal = function() {
mapRegions = [];
};
/**
* 외부 작업장소 선택 (외근/연차/휴무 등)
*/
window.selectExternalWorkplace = function() {
const manualIndex = window.currentManualIndex;
// 외부 작업장소 ID는 0 또는 특별한 값으로 설정 (DB에 저장시 처리 필요)
const externalCategoryId = 0;
const externalCategoryName = '외부';
const externalWorkplaceId = 0;
const externalWorkplaceName = '외부 (외근/연차/휴무)';
// hidden input에 값 설정
document.getElementById(`workplaceCategory_${manualIndex}`).value = externalCategoryId;
document.getElementById(`workplace_${manualIndex}`).value = externalWorkplaceId;
// 선택 결과 표시
const displayDiv = document.getElementById(`workplaceDisplay_${manualIndex}`);
if (displayDiv) {
displayDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: #0284c7; font-weight: 600;">
<span>✓</span>
<span>외부 선택됨</span>
</div>
<div style="font-size: 0.8rem; color: #111827; font-weight: 500;">
<div>🌐 ${escapeHtml(externalWorkplaceName)}</div>
</div>
`;
displayDiv.style.background = '#f0f9ff';
displayDiv.style.borderColor = '#0ea5e9';
}
// 모달 닫기
document.getElementById('workplaceModal').style.display = 'none';
showMessage('외부 작업장소가 선택되었습니다.', 'success');
};
/**
* 수동 작업보고서 제출
*/
@@ -1323,7 +1371,8 @@ window.submitManualWorkReport = async function(manualIndex) {
// 부적합 원인 가져오기
const defects = tempDefects[manualIndex] || [];
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].error_type_id : null;
// item_id를 error_type_id로 사용 (issue_report_items.item_id)
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
// 필수 필드 검증
if (!workerId) {
@@ -1346,7 +1395,7 @@ window.submitManualWorkReport = async function(manualIndex) {
showMessage('작업을 선택해주세요.', 'error');
return;
}
if (!workplaceId) {
if (workplaceId === '' || workplaceId === null || workplaceId === undefined) {
showMessage('작업장소를 선택해주세요.', 'error');
return;
}
@@ -1361,59 +1410,234 @@ window.submitManualWorkReport = async function(manualIndex) {
}
// 부적합 원인 유효성 검사 (issue_report_id 또는 error_type_id 필요)
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id);
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) {
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
return;
}
// 서비스 레이어가 기대하는 형식으로 변환
// 주의: 서비스에서 task_id를 work_type_id 컬럼에 매핑함
const reportData = {
worker_id: parseInt(workerId),
project_id: parseInt(projectId),
work_type_id: parseInt(workTypeId),
task_id: parseInt(taskId),
report_date: reportDate,
workplace_category_id: parseInt(workplaceCategoryId),
workplace_id: parseInt(workplaceId),
start_time: null,
end_time: null,
total_hours: totalHours,
error_hours: errorHours,
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
work_status_id: errorHours > 0 ? 2 : 1
worker_id: parseInt(workerId),
work_entries: [{
project_id: parseInt(projectId),
task_id: parseInt(taskId), // 서비스에서 work_type_id로 매핑됨
work_hours: totalHours,
work_status_id: errorHours > 0 ? 2 : 1,
error_type_id: errorTypeId ? parseInt(errorTypeId) : null
}]
};
try {
const response = await window.apiCall('/daily-work-reports', 'POST', reportData);
// 429 오류 재시도 로직 포함
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) {
const waitTime = (i + 1) * 2000;
showMessage(`서버가 바쁩니다. ${waitTime/1000}초 후 재시도...`, 'loading');
await new Promise(r => setTimeout(r, waitTime));
continue;
}
throw err;
}
}
if (!response.success) {
throw new Error(response.message || '작업보고서 제출 실패');
}
// 부적합 원인이 있으면 저장 (이슈 기반 또는 레거시)
if (defects.length > 0 && response.data?.workReport_ids?.[0]) {
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.error_type_id) && d.defect_hours > 0);
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/${response.data.workReport_ids[0]}/defects`, 'PUT', {
await window.apiCall(`/daily-work-reports/${reportId}/defects`, 'PUT', {
defects: validDefects
});
}
}
showSaveResultModal(
'success',
'작업보고서 제출 완료',
'작업보고서가 성공적으로 제출되었습니다.'
);
// 행 제거 (부적합 임시 데이터도 함께 삭제됨)
removeManualWorkRow(manualIndex);
// 목록 새로고침
await loadIncompleteTbms();
showMessage('작업보고서가 제출되었습니다.', 'success');
// 남은 행이 없으면 완료 메시지
const remainingRows = document.querySelectorAll('#manualWorkTableBody tr[data-index]');
if (remainingRows.length === 0) {
showSaveResultModal(
'success',
'작업보고서 제출 완료',
'모든 작업보고서가 성공적으로 제출되었습니다.'
);
}
} catch (error) {
console.error('수동 작업보고서 제출 오류:', error);
showSaveResultModal('error', '제출 실패', error.message);
showMessage('제출 실패: ' + error.message, 'error');
}
};
/**
* 딜레이 함수
*/
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
/**
* API 호출 (429 재시도 포함)
*/
async function apiCallWithRetry(url, method, data, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await window.apiCall(url, method, data);
return response;
} catch (error) {
// 429 Rate Limit 오류인 경우 재시도
if (error.message && error.message.includes('429') || error.message.includes('너무 많은 요청')) {
if (attempt < maxRetries) {
const waitTime = attempt * 2000; // 2초, 4초, 6초 대기
console.log(`Rate limit 도달. ${waitTime/1000}초 후 재시도... (${attempt}/${maxRetries})`);
await delay(waitTime);
continue;
}
}
throw error;
}
}
}
/**
* 수동 작업보고서 일괄 제출
*/
window.submitAllManualWorkReports = async function() {
const rows = document.querySelectorAll('#manualWorkTableBody tr[data-index]');
if (rows.length === 0) {
showMessage('제출할 작업보고서가 없습니다.', 'error');
return;
}
// 확인 다이얼로그
if (!confirm(`${rows.length}개의 작업보고서를 일괄 제출하시겠습니까?`)) {
return;
}
let successCount = 0;
let failCount = 0;
const errors = [];
let currentIndex = 0;
showMessage(`작업보고서 제출 중... (0/${rows.length})`, 'loading');
// 각 행을 순차적으로 제출 (딜레이 포함)
for (const row of rows) {
currentIndex++;
const manualIndex = row.dataset.index;
// Rate Limit 방지를 위한 딜레이 (1초)
if (currentIndex > 1) {
await delay(1000);
}
showMessage(`작업보고서 제출 중... (${currentIndex}/${rows.length})`, 'loading');
try {
const workerId = document.getElementById(`worker_${manualIndex}`).value;
const reportDate = document.getElementById(`date_${manualIndex}`).value;
const projectId = document.getElementById(`project_${manualIndex}`).value;
const workTypeId = document.getElementById(`workType_${manualIndex}`).value;
const taskId = document.getElementById(`task_${manualIndex}`).value;
const workplaceCategoryId = document.getElementById(`workplaceCategory_${manualIndex}`).value;
const workplaceId = document.getElementById(`workplace_${manualIndex}`).value;
const totalHours = parseFloat(document.getElementById(`totalHours_${manualIndex}`).value);
// 부적합 원인 가져오기
const defects = tempDefects[manualIndex] || [];
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
// item_id를 error_type_id로 사용 (issue_report_items.item_id)
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
// 필수 필드 검증
if (!workerId || !reportDate || !projectId || !workTypeId || !taskId || !totalHours || totalHours <= 0) {
errors.push(`${manualIndex}: 필수 항목 누락`);
failCount++;
continue;
}
if (workplaceId === '' || workplaceId === null || workplaceId === undefined) {
errors.push(`${manualIndex}: 작업장소 미선택`);
failCount++;
continue;
}
// 서비스 레이어가 기대하는 형식으로 변환
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 response = await apiCallWithRetry('/daily-work-reports', 'POST', reportData);
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 apiCallWithRetry(`/daily-work-reports/${reportId}/defects`, 'PUT', {
defects: validDefects
});
}
}
// 성공 - 행 제거
removeManualWorkRow(manualIndex);
successCount++;
} catch (error) {
console.error(`${manualIndex} 제출 오류:`, error);
errors.push(`${manualIndex}: ${error.message}`);
failCount++;
}
}
// 로딩 메시지 숨기기
hideMessage();
// 결과 표시
let resultMessage = `성공: ${successCount}`;
if (failCount > 0) {
resultMessage += `, 실패: ${failCount}`;
}
if (failCount > 0 && errors.length > 0) {
showSaveResultModal(
'warning',
'일괄 제출 완료 (일부 실패)',
`${resultMessage}\n\n실패 원인:\n${errors.slice(0, 5).join('\n')}${errors.length > 5 ? `\n... 외 ${errors.length - 5}` : ''}`
);
} else {
showSaveResultModal(
'success',
'일괄 제출 완료',
resultMessage
);
}
};
@@ -1506,6 +1730,10 @@ function renderCompletedReports(reports) {
<span class="label">공정:</span>
<span class="value">${escapeHtml(report.work_type_name || '-')}</span>
</div>
<div class="info-row">
<span class="label">작업:</span>
<span class="value">${escapeHtml(report.task_name || '-')}</span>
</div>
<div class="info-row">
<span class="label">작업시간:</span>
<span class="value">${parseFloat(report.total_hours || report.work_hours || 0)}시간</span>
@@ -1537,12 +1765,207 @@ function renderCompletedReports(reports) {
</div>
` : ''}
</div>
<div class="report-actions" style="display: flex; gap: 0.5rem; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #e5e7eb;">
<button type="button" class="btn btn-sm btn-secondary" onclick='openEditReportModal(${JSON.stringify(report).replace(/'/g, "&#39;")})' style="flex: 1; padding: 0.4rem 0.6rem; font-size: 0.75rem;">
✏️ 수정
</button>
<button type="button" class="btn btn-sm btn-danger" onclick="deleteWorkReport(${report.id})" style="flex: 1; padding: 0.4rem 0.6rem; font-size: 0.75rem; background: #fee2e2; color: #dc2626; border: 1px solid #fecaca;">
🗑️ 삭제
</button>
</div>
</div>
`).join('');
container.innerHTML = html;
}
/**
* 작업보고서 수정 모달 열기
*/
window.openEditReportModal = function(report) {
// 수정 모달이 없으면 동적 생성
let modal = document.getElementById('editReportModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'editReportModal';
modal.className = 'modal-overlay';
modal.style.cssText = 'display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1003; align-items: center; justify-content: center;';
modal.innerHTML = `
<div class="modal-container" style="background: white; border-radius: 8px; max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; margin: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
<div class="modal-header" style="padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;">
<h2 style="font-size: 1.1rem; font-weight: 600; color: #111827; margin: 0;">작업보고서 수정</h2>
<button class="modal-close" onclick="closeEditReportModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #6b7280;">&times;</button>
</div>
<div class="modal-body" style="padding: 1.5rem;">
<form id="editReportForm">
<input type="hidden" id="editReportId">
<div class="form-group" style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업자</label>
<input type="text" id="editWorkerName" readonly style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px; background: #f3f4f6;">
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">프로젝트</label>
<select id="editProjectId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
${projects.map(p => `<option value="${p.project_id}">${escapeHtml(p.project_name)}</option>`).join('')}
</select>
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">공정</label>
<select id="editWorkTypeId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;" onchange="loadTasksForEdit()">
${workTypes.map(wt => `<option value="${wt.id}">${escapeHtml(wt.name)}</option>`).join('')}
</select>
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업</label>
<select id="editTaskId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
<option value="">작업 선택</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업시간 (시간)</label>
<input type="number" id="editWorkHours" step="0.5" min="0" max="24" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">작업상태</label>
<select id="editWorkStatusId" class="form-input" style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 4px;">
${workStatusTypes.map(ws => `<option value="${ws.id}">${escapeHtml(ws.name)}</option>`).join('')}
</select>
</div>
</form>
</div>
<div class="modal-footer" style="padding: 1rem 1.5rem; border-top: 1px solid #e5e7eb; display: flex; gap: 0.75rem; justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="closeEditReportModal()" style="padding: 0.5rem 1rem;">취소</button>
<button type="button" class="btn btn-primary" onclick="saveEditedReport()" style="padding: 0.5rem 1rem; background: #f59e0b; border: none; color: white; border-radius: 4px; cursor: pointer;">저장</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
// 폼에 데이터 채우기
document.getElementById('editReportId').value = report.id;
document.getElementById('editWorkerName').value = report.worker_name || '작업자';
document.getElementById('editProjectId').value = report.project_id || '';
document.getElementById('editWorkHours').value = report.work_hours || report.total_hours || 0;
document.getElementById('editWorkStatusId').value = report.work_status_id || 1;
// 공정 선택 후 작업 목록 로드
const workTypeSelect = document.getElementById('editWorkTypeId');
// work_type_id가 실제로는 task_id를 저장하고 있으므로, task에서 work_type을 찾아야 함
// 일단 task 기반으로 찾기 시도
loadTasksForEdit().then(() => {
const taskSelect = document.getElementById('editTaskId');
// work_type_id 컬럼에 저장된 값이 실제로는 task_id
if (report.work_type_id) {
taskSelect.value = report.work_type_id;
}
});
modal.style.display = 'flex';
};
/**
* 수정 모달용 작업 목록 로드
*/
window.loadTasksForEdit = async function() {
const workTypeId = document.getElementById('editWorkTypeId').value;
const taskSelect = document.getElementById('editTaskId');
if (!workTypeId) {
taskSelect.innerHTML = '<option value="">공정을 먼저 선택하세요</option>';
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}">${escapeHtml(t.task_name)}</option>`).join('');
} catch (error) {
console.error('작업 목록 로드 오류:', error);
taskSelect.innerHTML = '<option value="">로드 실패</option>';
}
};
/**
* 수정 모달 닫기
*/
window.closeEditReportModal = function() {
const modal = document.getElementById('editReportModal');
if (modal) {
modal.style.display = 'none';
}
};
/**
* 수정된 보고서 저장
*/
window.saveEditedReport = async function() {
const reportId = document.getElementById('editReportId').value;
const projectId = document.getElementById('editProjectId').value;
const taskId = document.getElementById('editTaskId').value;
const workHours = parseFloat(document.getElementById('editWorkHours').value);
const workStatusId = document.getElementById('editWorkStatusId').value;
if (!projectId || !taskId || !workHours) {
showMessage('필수 항목을 모두 입력해주세요.', 'error');
return;
}
try {
const updateData = {
project_id: parseInt(projectId),
work_type_id: parseInt(taskId), // task_id가 work_type_id 컬럼에 저장됨
work_hours: workHours,
work_status_id: parseInt(workStatusId)
};
const response = await window.apiCall(`/daily-work-reports/${reportId}`, 'PUT', updateData);
if (response.success) {
showMessage('작업보고서가 수정되었습니다.', 'success');
closeEditReportModal();
loadCompletedReports(); // 목록 새로고침
} else {
throw new Error(response.message || '수정 실패');
}
} catch (error) {
console.error('작업보고서 수정 오류:', error);
showMessage('수정 실패: ' + error.message, 'error');
}
};
/**
* 작업보고서 삭제
*/
window.deleteWorkReport = async function(reportId) {
if (!confirm('이 작업보고서를 삭제하시겠습니까?')) {
return;
}
try {
const response = await window.apiCall(`/daily-work-reports/${reportId}`, 'DELETE');
if (response.success) {
showMessage('작업보고서가 삭제되었습니다.', 'success');
loadCompletedReports(); // 목록 새로고침
} else {
throw new Error(response.message || '삭제 실패');
}
} catch (error) {
console.error('작업보고서 삭제 오류:', error);
showMessage('삭제 실패: ' + error.message, 'error');
}
};
// =================================================================
// 기존 함수들
// =================================================================
@@ -1780,15 +2203,16 @@ async function loadProjects() {
async function loadWorkTypes() {
try {
const data = await window.apiCall(`/daily-work-reports/work-types`);
const response = await window.apiCall(`/daily-work-reports/work-types`);
const data = response.data || response;
if (Array.isArray(data) && data.length > 0) {
workTypes = data;
console.log('✅ 작업 유형 API 사용 (통합 설정)');
console.log('✅ 작업 유형 API 사용 (통합 설정):', workTypes.length + '개');
return;
}
throw new Error('API 실패');
} catch (error) {
console.log('⚠️ 작업 유형 API 사용 불가, 기본값 사용');
console.log('⚠️ 작업 유형 API 사용 불가, 기본값 사용:', error.message);
workTypes = [
{ id: 1, name: 'Base' },
{ id: 2, name: 'Vessel' },
@@ -1799,10 +2223,11 @@ async function loadWorkTypes() {
async function loadWorkStatusTypes() {
try {
const data = await window.apiCall(`/daily-work-reports/work-status-types`);
const response = await window.apiCall(`/daily-work-reports/work-status-types`);
const data = response.data || response;
if (Array.isArray(data) && data.length > 0) {
workStatusTypes = data;
console.log('✅ 업무 상태 유형 API 사용 (통합 설정)');
console.log('✅ 업무 상태 유형 API 사용 (통합 설정):', workStatusTypes.length + '개');
return;
}
throw new Error('API 실패');
@@ -1818,7 +2243,8 @@ async function loadWorkStatusTypes() {
async function loadErrorTypes() {
// 레거시 에러 유형 로드 (호환성)
try {
const data = await window.apiCall(`/daily-work-reports/error-types`);
const response = await window.apiCall(`/daily-work-reports/error-types`);
const data = response.data || response;
if (Array.isArray(data) && data.length > 0) {
errorTypes = data;
}
@@ -3610,10 +4036,11 @@ function updateHiddenDefectFields(index) {
// 총 부적합 시간 계산
const totalErrorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
// hidden input에 대표 error_type_id 저장 (첫 번째 값)
// hidden input에 대표 error_type_id 저장 (첫 번째 값, item_id fallback)
const errorTypeInput = document.getElementById(`errorType_${index}`);
if (errorTypeInput && defects.length > 0 && defects[0].error_type_id) {
errorTypeInput.value = defects[0].error_type_id;
const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null;
if (errorTypeInput && errorTypeId) {
errorTypeInput.value = errorTypeId;
} else if (errorTypeInput) {
errorTypeInput.value = '';
}
@@ -3635,8 +4062,8 @@ function updateDefectSummary(index) {
if (!summaryEl) return;
const defects = tempDefects[index] || [];
// 이슈 기반 또는 레거시 부적합 중 시간이 입력된 것만 유효
const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.error_type_id) && d.defect_hours > 0);
// 이슈 기반 또는 레거시 부적합 중 시간이 입력된 것만 유효 (item_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) {
summaryEl.textContent = '없음';
@@ -3655,9 +4082,16 @@ function updateDefectSummary(index) {
const issue = issues.find(i => i.report_id == validDefects[0].issue_report_id);
typeName = issue?.issue_item_name || issue?.issue_category_name || '부적합';
}
} else if (validDefects[0].item_id) {
// 신규 방식 - issue_report_items에서 이름 찾기
typeName = issueItems.find(i => i.item_id == validDefects[0].item_id)?.item_name || '부적합';
} else if (validDefects[0].category_id) {
// 카테고리만 선택된 경우
typeName = issueCategories.find(c => c.category_id == validDefects[0].category_id)?.category_name || '부적합';
} else if (validDefects[0].error_type_id) {
// 레거시 - error_types에서 이름 찾기
typeName = errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합';
// 레거시 - error_types에서 이름 찾기 또는 issue_report_items에서 찾기
typeName = issueItems.find(i => i.item_id == validDefects[0].error_type_id)?.item_name ||
errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합';
}
summaryEl.textContent = `${typeName} ${totalHours}h`;
} else {