Files
TK-FB-Project/web-ui/js/daily-work-report.js
Hyungi Ahn 36f110c90a fix: 보안 취약점 수정 및 XSS 방지 적용
## 백엔드 보안 수정
- 하드코딩된 비밀번호 및 JWT 시크릿 폴백 제거
- SQL Injection 방지를 위한 화이트리스트 검증 추가
- 인증 미적용 API 라우트에 requireAuth 미들웨어 적용
- CSRF 보호 미들웨어 구현 (csrf.js)
- 파일 업로드 보안 유틸리티 추가 (fileUploadSecurity.js)
- 비밀번호 정책 검증 유틸리티 추가 (passwordValidator.js)

## 프론트엔드 XSS 방지
- api-base.js에 전역 escapeHtml() 함수 추가
- 17개 주요 JS 파일에 escapeHtml 적용:
  - tbm.js, daily-patrol.js, daily-work-report.js
  - task-management.js, workplace-status.js
  - equipment-detail.js, equipment-management.js
  - issue-detail.js, issue-report.js
  - vacation-common.js, worker-management.js
  - safety-report-list.js, nonconformity-list.js
  - project-management.js, workplace-management.js

## 정리
- 백업 폴더 및 빈 파일 삭제
- SECURITY_GUIDE.md 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 06:33:10 +09:00

3673 lines
123 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.js - 브라우저 호환 버전
// =================================================================
// 🌐 API 설정 (window 객체에서 가져오기)
// =================================================================
// API 설정은 api-config.js에서 window 객체에 설정됨
// 전역 변수
let workTypes = [];
let workStatusTypes = [];
let errorTypes = []; // 레거시 호환용
let issueCategories = []; // 신고 카테고리 (nonconformity)
let issueItems = []; // 신고 아이템
let workers = [];
let projects = [];
let selectedWorkers = new Set();
let workEntryCounter = 0;
let currentStep = 1;
let editingWorkId = null; // 수정 중인 작업 ID
let incompleteTbms = []; // 미완료 TBM 작업 목록
let currentTab = 'tbm'; // 현재 활성 탭
// 부적합 원인 관리
let currentDefectIndex = null; // 현재 편집 중인 행 인덱스
let tempDefects = {}; // 임시 부적합 원인 저장 { index: [{ error_type_id, defect_hours, note }] }
// 작업장소 지도 관련 변수
let mapCanvas = null;
let mapCtx = null;
let mapImage = null;
let mapRegions = [];
let selectedWorkplace = null;
let selectedWorkplaceName = null;
let selectedWorkplaceCategory = null;
let selectedWorkplaceCategoryName = null;
// 시간 선택 관련 변수
let currentEditingField = null; // { index, type: 'total' | 'error' }
let currentTimeValue = 0;
// 당일 신고 리마인더 관련 변수
let dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] } - 날짜별 신고 캐시
// =================================================================
// TBM 작업보고 관련 함수
// =================================================================
/**
* 탭 전환 함수
*/
window.switchTab = function(tab) {
currentTab = tab;
const tbmBtn = document.getElementById('tbmReportTab');
const completedBtn = document.getElementById('completedReportTab');
const tbmSection = document.getElementById('tbmReportSection');
const completedSection = document.getElementById('completedReportSection');
// 모든 탭 버튼 비활성화
tbmBtn.classList.remove('active');
completedBtn.classList.remove('active');
// 모든 섹션 숨기기
tbmSection.style.display = 'none';
completedSection.style.display = 'none';
// 선택된 탭 활성화
if (tab === 'tbm') {
tbmBtn.classList.add('active');
tbmSection.style.display = 'block';
loadIncompleteTbms(); // TBM 목록 로드
} else if (tab === 'completed') {
completedBtn.classList.add('active');
completedSection.style.display = 'block';
// 오늘 날짜로 초기화
document.getElementById('completedReportDate').value = getKoreaToday();
loadCompletedReports();
}
};
/**
* 미완료 TBM 작업 로드
*/
async function loadIncompleteTbms() {
try {
const response = await window.apiCall('/tbm/sessions/incomplete-reports');
if (!response.success) {
throw new Error(response.message || '미완료 TBM 조회 실패');
}
let data = response.data || [];
// 사용자 권한 확인 및 필터링
const user = getUser();
if (user && user.role !== 'Admin' && user.access_level !== 'system') {
// 일반 사용자: 자신이 생성한 세션만 표시
const userId = user.user_id;
data = data.filter(tbm => tbm.created_by === userId);
}
// 관리자는 모든 데이터 표시
incompleteTbms = data;
// 각 세션 날짜에 대해 관련 신고 조회
await loadDailyIssuesForTbms();
renderTbmWorkList();
} catch (error) {
console.error('미완료 TBM 로드 오류:', error);
showMessage('TBM 작업 목록을 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* TBM 세션들에 대한 당일 신고 조회
* - 각 세션 날짜별로 관련 신고를 가져와서 캐시에 저장
*/
async function loadDailyIssuesForTbms() {
if (!incompleteTbms || incompleteTbms.length === 0) {
console.log('[작업보고서] 미완료 TBM 없음, 신고 조회 건너뜀');
return;
}
console.log('[작업보고서] TBM 세션 수:', incompleteTbms.length);
// 세션별로 고유한 날짜 + 작업장 조합 수집
const sessionKeys = new Set();
incompleteTbms.forEach(tbm => {
const dateStr = formatDateForApi(tbm.session_date);
console.log('[작업보고서] TBM 세션 날짜:', tbm.session_date, '→ 변환:', dateStr);
if (dateStr) {
// 날짜_작업장ID_프로젝트ID 형태로 키 생성
const key = `${dateStr}_${tbm.workplace_id || 0}_${tbm.project_id || 0}`;
sessionKeys.add(key);
}
});
// 각 날짜에 대해 신고 조회
const uniqueDates = [...new Set([...sessionKeys].map(k => k.split('_')[0]))];
console.log('[작업보고서] 조회할 날짜들:', uniqueDates);
for (const dateStr of uniqueDates) {
if (dailyIssuesCache[dateStr]) {
console.log(`[작업보고서] 캐시 사용 (${dateStr}):`, dailyIssuesCache[dateStr].length, '건');
continue;
}
try {
console.log(`[작업보고서] 신고 API 호출: /work-issues?start_date=${dateStr}&end_date=${dateStr}`);
const response = await window.apiCall(`/work-issues?start_date=${dateStr}&end_date=${dateStr}`);
if (response.success) {
dailyIssuesCache[dateStr] = response.data || [];
console.log(`[작업보고서] 신고 로드 완료 (${dateStr}):`, dailyIssuesCache[dateStr].length, '건');
} else {
console.warn(`[작업보고서] 신고 API 실패:`, response);
dailyIssuesCache[dateStr] = [];
}
} catch (error) {
console.error(`[작업보고서] 신고 조회 오류 (${dateStr}):`, error);
dailyIssuesCache[dateStr] = [];
}
}
console.log('[작업보고서] 전체 신고 캐시:', dailyIssuesCache);
}
/**
* 특정 날짜의 모든 신고 반환 (작업장소 관계없이)
* - 참고용으로 해당 날짜에 발생한 모든 신고를 표시
* @param {string} dateStr - 날짜 (YYYY-MM-DD)
* @param {number} workplaceId - 작업장소 ID (현재 미사용, 향후 하이라이트 용도)
* @param {number} projectId - 프로젝트 ID (현재 미사용)
* @returns {Array} 해당 날짜의 모든 신고 목록
*/
function getRelatedIssues(dateStr, workplaceId, projectId) {
const issues = dailyIssuesCache[dateStr] || [];
// 해당 날짜의 모든 신고를 반환 (작업장소 필터 제거)
// 사용자가 참고하여 관련 여부를 직접 판단하도록 함
return issues;
}
/**
* 날짜를 API 형식(YYYY-MM-DD)으로 변환 - 로컬 시간대 기준
*/
function formatDateForApi(date) {
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
// 문자열인 경우 Date 객체로 변환
dateObj = new Date(date);
} else {
return null;
}
// 로컬 시간대 기준으로 날짜 추출 (UTC 변환 방지)
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 사용자 정보 가져오기 (auth-check.js와 동일한 로직)
*/
function getUser() {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
}
/**
* TBM 작업 목록 렌더링 (날짜별 > 세션별 그룹화)
* - 날짜별로 접기/펼치기 가능
* - 날짜 헤더에 이슈 요약 표시
*/
function renderTbmWorkList() {
const container = document.getElementById('tbmWorkList');
// 1단계: 날짜별로 그룹화
const byDate = {};
if (incompleteTbms && incompleteTbms.length > 0) {
incompleteTbms.forEach((tbm, index) => {
const dateStr = formatDateForApi(tbm.session_date);
if (!byDate[dateStr]) {
byDate[dateStr] = {
date: tbm.session_date,
sessions: {}
};
}
// 2단계: 날짜 내에서 세션별로 그룹화
const sessionKey = `${tbm.session_id}_${dateStr}`;
if (!byDate[dateStr].sessions[sessionKey]) {
byDate[dateStr].sessions[sessionKey] = {
session_id: tbm.session_id,
session_date: tbm.session_date,
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));
// 레거시 호환: 기존 groupedTbms 구조도 유지
const groupedTbms = {};
sortedDates.forEach(dateStr => {
Object.assign(groupedTbms, byDate[dateStr].sessions);
});
let html = `
<div style="margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">작업보고서 목록</h3>
<button type="button" class="btn-add-work" onclick="addManualWorkRow()">
작업 추가
</button>
</div>
`;
// 수동 입력 섹션 먼저 추가 (맨 위)
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>
<div class="tbm-table-container">
<table class="tbm-work-table">
<thead>
<tr>
<th>작업자</th>
<th>날짜</th>
<th>프로젝트</th>
<th>공정</th>
<th>작업</th>
<th>작업장소</th>
<th>작업시간</th>
<th>부적합</th>
<th>제출</th>
</tr>
</thead>
<tbody id="manualWorkTableBody">
<!-- 수동 입력 행들이 여기에 추가됩니다 -->
</tbody>
</table>
</div>
</div>
`;
// 날짜별로 테이블 생성 (접기/펼치기 가능)
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 relatedIssues = getRelatedIssues(dateStr);
const nonconformityCount = relatedIssues.filter(i => i.category_type === 'nonconformity').length;
const safetyCount = relatedIssues.filter(i => i.category_type === 'safety').length;
const hasIssues = relatedIssues.length > 0;
// 요일 계산
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dayOfWeek = dayNames[new Date(dateStr).getDay()];
// 오늘 날짜인지 확인
const today = formatDateForApi(new Date());
const isToday = dateStr === today;
// 날짜 그룹 시작 (기본적으로 오늘만 펼침)
const isExpanded = isToday || dateIndex === 0;
html += `
<div class="date-group ${isExpanded ? 'expanded' : 'collapsed'}" data-date="${dateStr}">
<div class="date-group-header ${hasIssues ? 'has-issues' : ''}" onclick="toggleDateGroup('${dateStr}')">
<div class="date-header-left">
<span class="date-toggle-icon">${isExpanded ? '▼' : '▶'}</span>
<span class="date-title">${formatDate(dateData.date)} (${dayOfWeek})</span>
${isToday ? '<span class="today-badge">오늘</span>' : ''}
</div>
<div class="date-header-center">
<span class="date-stat">세션 ${sessions.length}개</span>
<span class="date-stat">작업자 ${totalWorkers}명</span>
</div>
<div class="date-header-right">
${hasIssues ? `
<div class="date-issue-summary">
${nonconformityCount > 0 ? `<span class="issue-badge nonconformity">부적합 ${nonconformityCount}</span>` : ''}
${safetyCount > 0 ? `<span class="issue-badge safety">안전 ${safetyCount}</span>` : ''}
</div>
` : '<span class="no-issues">신고 없음</span>'}
</div>
</div>
<div class="date-group-content" style="display: ${isExpanded ? 'block' : 'none'};">
`;
// 신고 리마인더 HTML 생성 (날짜 그룹 내부)
if (hasIssues) {
html += `
<div class="issue-reminder-section">
<div class="issue-reminder-header">
<span class="issue-reminder-icon">⚠️</span>
<span class="issue-reminder-title">당일 신고된 문제</span>
<span class="issue-reminder-count">${relatedIssues.length}건</span>
</div>
<div class="issue-reminder-list">
${relatedIssues.slice(0, 5).map(issue => {
// 아이템명과 추가설명 조합
let itemText = issue.issue_item_name || '';
if (issue.additional_description) {
itemText = itemText ? `${itemText} - ${issue.additional_description}` : issue.additional_description;
}
return `
<div class="issue-reminder-item ${issue.category_type === 'safety' ? 'safety' : 'nonconformity'}">
<span class="issue-type-badge">${issue.category_type === 'safety' ? '안전' : '부적합'}</span>
<span class="issue-category">${issue.issue_category_name || ''}</span>
<span class="issue-item">${itemText || '-'}</span>
<span class="issue-location">${issue.workplace_name || issue.custom_location || ''}</span>
<span class="issue-status status-${issue.status}">${getStatusLabel(issue.status)}</span>
</div>
`}).join('')}
${relatedIssues.length > 5 ? `<div class="issue-reminder-more">외 ${relatedIssues.length - 5}건 더 있음</div>` : ''}
</div>
<div class="issue-reminder-hint">
💡 위 문제로 인해 작업이 지연되었다면, 아래에서 부적합 시간을 추가해주세요.
</div>
</div>
`;
}
// 해당 날짜의 각 세션별로 테이블 생성
sessions.forEach(group => {
const key = `${group.session_id}_${dateStr}`;
html += `
<div class="tbm-session-group" data-session-key="${key}">
<div class="tbm-session-header">
<span class="tbm-session-badge">TBM 세션</span>
<span class="tbm-session-creator">작성자: ${group.created_by_name}</span>
<span class="tbm-session-count">${group.items.length}명</span>
</div>
<div class="tbm-table-container">
<table class="tbm-work-table">
<thead>
<tr>
<th>작업자</th>
<th>프로젝트</th>
<th>공정</th>
<th>작업</th>
<th>작업장소</th>
<th>작업시간</th>
<th>부적합</th>
<th>제출</th>
</tr>
</thead>
<tbody>
${group.items.map(tbm => {
const index = tbm.originalIndex;
// 이 작업자의 작업장소와 관련된 이슈가 있는지 확인 (부적합 버튼 강조용)
const hasRelatedIssue = relatedIssues.some(issue => {
if (issue.category_type !== 'nonconformity') return false;
// 작업장소 매칭
if (tbm.workplace_id && issue.workplace_id) {
return tbm.workplace_id === issue.workplace_id;
}
if (tbm.workplace_name && (issue.workplace_name || issue.custom_location)) {
const issueLocation = issue.workplace_name || issue.custom_location || '';
return issueLocation.includes(tbm.workplace_name) || tbm.workplace_name.includes(issueLocation);
}
return false;
});
return `
<tr data-index="${index}" data-type="tbm" data-session-key="${key}">
<td>
<div class="worker-cell">
<strong>${tbm.worker_name || '작업자'}</strong>
<div class="worker-job-type">${tbm.job_type || '-'}</div>
</div>
</td>
<td>${tbm.project_name || '-'}</td>
<td>${tbm.work_type_name || '-'}</td>
<td>${tbm.task_name || '-'}</td>
<td>
<div class="workplace-cell">
<div>${tbm.category_name || ''}</div>
<div>${tbm.workplace_name || '-'}</div>
</div>
</td>
<td>
<input type="hidden" id="totalHours_${index}" value="" required>
<div class="time-input-trigger placeholder"
id="totalHoursDisplay_${index}"
onclick="openTimePicker(${index}, 'total')">
시간 선택
</div>
</td>
<td>
<input type="hidden" id="errorHours_${index}" value="0">
<input type="hidden" id="errorType_${index}" value="">
<button type="button"
class="btn-defect-toggle ${hasRelatedIssue ? 'has-related-issue' : ''}"
id="defectToggle_${index}"
onclick="toggleDefectArea(${index})">
<span id="defectSummary_${index}">없음</span>
</button>
</td>
<td>
<button type="button"
class="btn-submit-compact"
onclick="submitTbmWorkReport(${index})">
제출
</button>
</td>
</tr>
<tr class="defect-row" id="defectRow_${index}" style="display: none;">
<td colspan="8" style="padding: 0.75rem; background: #fef3c7;">
<div class="defect-inline-area" id="defectArea_${index}">
<div class="defect-list" id="defectList_${index}">
<!-- 부적합 원인 목록 (renderInlineDefectList에서 렌더링) -->
</div>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
<div class="batch-submit-container">
<button type="button"
class="btn-batch-submit"
onclick="batchSubmitTbmSession('${key}')">
📤 이 세션 일괄제출 (${group.items.length}건)
</button>
</div>
</div>
`;
});
// 날짜 그룹 닫기
html += `
</div>
</div>
`;
});
container.innerHTML = html;
}
/**
* 날짜 그룹 접기/펼치기 토글
*/
window.toggleDateGroup = function(dateStr) {
const group = document.querySelector(`.date-group[data-date="${dateStr}"]`);
if (!group) return;
const content = group.querySelector('.date-group-content');
const icon = group.querySelector('.date-toggle-icon');
const isExpanded = group.classList.contains('expanded');
if (isExpanded) {
group.classList.remove('expanded');
group.classList.add('collapsed');
content.style.display = 'none';
icon.textContent = '▶';
} else {
group.classList.remove('collapsed');
group.classList.add('expanded');
content.style.display = 'block';
icon.textContent = '▼';
}
};
/**
* 부적합 시간 입력 처리
*/
window.calculateRegularHours = function(index) {
const errorInput = document.getElementById(`errorHours_${index}`);
const errorTypeSelect = document.getElementById(`errorType_${index}`);
const errorTypeNone = document.getElementById(`errorTypeNone_${index}`);
const errorHours = parseFloat(errorInput.value) || 0;
// 부적합 시간이 있으면 원인 선택 표시
if (errorHours > 0) {
errorTypeSelect.style.display = 'inline-block';
if (errorTypeNone) errorTypeNone.style.display = 'none';
} else {
errorTypeSelect.style.display = 'none';
if (errorTypeNone) errorTypeNone.style.display = 'inline';
}
};
/**
* TBM 작업보고서 제출
*/
window.submitTbmWorkReport = async function(index) {
const tbm = incompleteTbms[index];
const totalHours = parseFloat(document.getElementById(`totalHours_${index}`).value);
const defects = 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].error_type_id : null;
// 필수 필드 검증
if (!totalHours || totalHours <= 0) {
showMessage('작업시간을 입력해주세요.', 'error');
return;
}
if (errorHours > totalHours) {
showMessage('부적합 처리 시간은 총 작업시간을 초과할 수 없습니다.', 'error');
return;
}
// 부적합 원인 유효성 검사 (issue_report_id 또는 category_id 또는 error_type_id 필요)
console.log('🔍 부적합 검증 시작:', defects.map(d => ({
defect_hours: d.defect_hours,
category_id: d.category_id,
item_id: d.item_id,
error_type_id: d.error_type_id,
issue_report_id: d.issue_report_id,
_saved: d._saved
})));
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id);
if (invalidDefects.length > 0) {
console.error('❌ 유효하지 않은 부적합:', invalidDefects);
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
return;
}
// 날짜를 YYYY-MM-DD 형식으로 변환
const reportDate = tbm.session_date instanceof Date
? tbm.session_date.toISOString().split('T')[0]
: (typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
? tbm.session_date.split('T')[0]
: 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.work_type_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
};
console.log('🔍 TBM 제출 데이터:', JSON.stringify(reportData, null, 2));
console.log('🔍 부적합 원인:', defects);
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.error_type_id) && d.defect_hours > 0);
console.log('📋 부적합 원인 필터링:', {
전체: defects.length,
유효: validDefects.length,
validDefects: validDefects.map(d => ({
category_id: d.category_id,
item_id: d.item_id,
defect_hours: d.defect_hours,
_saved: d._saved
}))
});
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 || ''
}));
console.log('📤 부적합 저장 요청:', defectsToSend);
const defectResponse = await window.apiCall(`/daily-work-reports/${response.data.report_id}/defects`, 'PUT', {
defects: defectsToSend
});
if (!defectResponse.success) {
console.error('❌ 부적합 저장 실패:', defectResponse);
showMessage('작업보고서는 저장되었으나 부적합 원인 저장에 실패했습니다.', 'warning');
} else {
console.log('✅ 부적합 저장 성공:', defectResponse);
}
} else {
console.log('⚠️ 유효한 부적합 항목이 없어 저장 건너뜀');
}
}
showSaveResultModal(
'success',
'작업보고서 제출 완료',
`${tbm.worker_name}의 작업보고서가 성공적으로 제출되었습니다.`,
response.data.tbm_completed ?
'모든 팀원의 작업보고서가 제출되어 TBM이 완료되었습니다.' :
response.data.completion_status
);
// 임시 부적합 데이터 삭제
delete tempDefects[index];
// 목록 새로고침
await loadIncompleteTbms();
} catch (error) {
console.error('TBM 작업보고서 제출 오류:', error);
showSaveResultModal('error', '제출 실패', error.message);
}
};
/**
* TBM 세션 일괄제출
*/
window.batchSubmitTbmSession = async function(sessionKey) {
// 해당 세션의 모든 항목 가져오기
const sessionRows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"]`);
if (sessionRows.length === 0) {
showMessage('제출할 항목이 없습니다.', 'error');
return;
}
// 1단계: 모든 항목 검증
const validationErrors = [];
const itemsToSubmit = [];
sessionRows.forEach((row, rowIndex) => {
const index = parseInt(row.getAttribute('data-index'));
const tbm = incompleteTbms[index];
const totalHours = parseFloat(document.getElementById(`totalHours_${index}`)?.value);
const errorHours = parseFloat(document.getElementById(`errorHours_${index}`)?.value) || 0;
const errorTypeId = document.getElementById(`errorType_${index}`)?.value;
// 검증
if (!totalHours || totalHours <= 0) {
validationErrors.push(`${tbm.worker_name}: 작업시간 미입력`);
return;
}
if (errorHours > totalHours) {
validationErrors.push(`${tbm.worker_name}: 부적합 시간이 총 작업시간 초과`);
return;
}
if (errorHours > 0 && !errorTypeId) {
validationErrors.push(`${tbm.worker_name}: 부적합 원인 미선택`);
return;
}
// 검증 통과한 항목 저장
const reportDate = tbm.session_date instanceof Date
? tbm.session_date.toISOString().split('T')[0]
: (typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
? tbm.session_date.split('T')[0]
: tbm.session_date);
itemsToSubmit.push({
index,
tbm,
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.work_type_id,
report_date: reportDate,
start_time: null,
end_time: null,
total_hours: totalHours,
error_hours: errorHours,
error_type_id: errorTypeId || null,
work_status_id: errorHours > 0 ? 2 : 1
}
});
});
// 검증 실패가 하나라도 있으면 전체 중단
if (validationErrors.length > 0) {
showSaveResultModal(
'error',
'일괄제출 검증 실패',
'모든 항목이 유효해야 제출할 수 있습니다.',
validationErrors
);
return;
}
// 2단계: 모든 항목 제출
const submitBtn = event.target;
submitBtn.disabled = true;
submitBtn.textContent = '제출 중...';
const results = {
success: [],
failed: []
};
try {
for (const item of itemsToSubmit) {
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', item.data);
if (response.success) {
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}`);
}
}
// 결과 표시
const totalCount = itemsToSubmit.length;
const successCount = results.success.length;
const failedCount = results.failed.length;
if (failedCount === 0) {
// 모두 성공
showSaveResultModal(
'success',
'일괄제출 완료',
`${totalCount}건의 작업보고서가 모두 성공적으로 제출되었습니다.`,
results.success.map(name => `${name}`)
);
} else if (successCount === 0) {
// 모두 실패
showSaveResultModal(
'error',
'일괄제출 실패',
`${totalCount}건의 작업보고서가 모두 실패했습니다.`,
results.failed.map(msg => `${msg}`)
);
} else {
// 일부 성공, 일부 실패
const details = [
...results.success.map(name => `${name} - 성공`),
...results.failed.map(msg => `${msg}`)
];
showSaveResultModal(
'warning',
'일괄제출 부분 완료',
`성공: ${successCount}건 / 실패: ${failedCount}`,
details
);
}
// 목록 새로고침
await loadIncompleteTbms();
} catch (error) {
console.error('일괄제출 오류:', error);
showSaveResultModal('error', '일괄제출 오류', error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = `📤 이 세션 일괄제출 (${sessionRows.length}건)`;
}
};
/**
* 수동 작업 추가
*/
window.addManualWorkRow = function() {
const tbody = document.getElementById('manualWorkTableBody');
if (!tbody) {
showMessage('수동 입력 테이블을 찾을 수 없습니다.', 'error');
return;
}
const manualIndex = `manual_${workEntryCounter++}`;
const newRow = document.createElement('tr');
newRow.setAttribute('data-index', manualIndex);
newRow.setAttribute('data-type', 'manual');
newRow.innerHTML = `
<td>
<select class="form-input-compact" id="worker_${manualIndex}" style="width: 120px;" required>
<option value="">작업자 선택</option>
${workers.map(w => `<option value="${escapeHtml(String(w.worker_id))}">${escapeHtml(w.worker_name)} (${escapeHtml(w.job_type || '-')})</option>`).join('')}
</select>
</td>
<td>
<input type="date" class="form-input-compact" id="date_${manualIndex}" value="${escapeHtml(getKoreaToday())}" required style="width: 130px;">
</td>
<td>
<select class="form-input-compact" id="project_${manualIndex}" style="width: 120px;" required>
<option value="">프로젝트 선택</option>
${projects.map(p => `<option value="${escapeHtml(String(p.project_id))}">${escapeHtml(p.project_name)}</option>`).join('')}
</select>
</td>
<td>
<select class="form-input-compact" id="workType_${manualIndex}" style="width: 120px;" required onchange="loadTasksForWorkType('${manualIndex}')">
<option value="">공정 선택</option>
${workTypes.map(wt => `<option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}</option>`).join('')}
</select>
</td>
<td>
<select class="form-input-compact" id="task_${manualIndex}" style="width: 120px;" required disabled>
<option value="">공정을 먼저 선택하세요</option>
</select>
</td>
<td style="min-width: 180px;">
<input type="hidden" id="workplaceCategory_${manualIndex}">
<input type="hidden" id="workplace_${manualIndex}">
<div id="workplaceDisplay_${manualIndex}" class="workplace-select-box" style="display: flex; flex-direction: column; gap: 0.25rem; padding: 0.5rem; background: #f9fafb; border: 2px solid #e5e7eb; border-radius: 6px; min-height: 60px; cursor: pointer;" onclick="openWorkplaceMapForManual('${manualIndex}')">
<div style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: #6b7280; font-weight: 500;">
<span>🗺️</span>
<span>작업장소</span>
</div>
<div id="workplaceText_${manualIndex}" style="font-size: 0.8rem; color: #9ca3af; font-style: italic;">
클릭하여 선택
</div>
</div>
</td>
<td>
<input type="hidden" id="totalHours_${manualIndex}" value="" required>
<div class="time-input-trigger placeholder"
id="totalHoursDisplay_${manualIndex}"
onclick="openTimePicker('${manualIndex}', 'total')">
시간 선택
</div>
</td>
<td>
<input type="hidden" id="errorHours_${manualIndex}" value="0">
<input type="hidden" id="errorType_${manualIndex}" value="">
<button type="button"
class="btn-defect-toggle"
id="defectToggle_${manualIndex}"
onclick="toggleDefectArea('${manualIndex}')">
<span id="defectSummary_${manualIndex}">없음</span>
</button>
</td>
<td>
<button type="button" class="btn-submit-compact" onclick="submitManualWorkReport('${manualIndex}')">
제출
</button>
<button type="button" class="btn-delete-compact" onclick="removeManualWorkRow('${manualIndex}')" style="margin-left: 4px;">
</button>
</td>
`;
tbody.appendChild(newRow);
// 부적합 인라인 영역 행 추가
const defectRow = document.createElement('tr');
defectRow.className = 'defect-row';
defectRow.id = `defectRow_${manualIndex}`;
defectRow.style.display = 'none';
defectRow.innerHTML = `
<td colspan="9" style="padding: 0; background: #fef3c7;">
<div class="defect-inline-area" id="defectArea_${manualIndex}">
<div class="defect-list" id="defectList_${manualIndex}">
<!-- 부적합 원인 목록 -->
</div>
<button type="button" class="btn-add-defect-inline" onclick="addInlineDefect('${manualIndex}')">
+ 부적합 추가
</button>
</div>
</td>
`;
tbody.appendChild(defectRow);
showMessage('새 작업 행이 추가되었습니다. 정보를 입력하고 제출하세요.', 'info');
};
/**
* 수동 작업 행 제거
*/
window.removeManualWorkRow = function(manualIndex) {
const row = document.querySelector(`tr[data-index="${manualIndex}"]`);
const defectRow = document.getElementById(`defectRow_${manualIndex}`);
if (row) {
row.remove();
}
if (defectRow) {
defectRow.remove();
}
// 임시 부적합 데이터도 삭제
delete tempDefects[manualIndex];
};
/**
* 공정 선택 시 작업 목록 로드
*/
window.loadTasksForWorkType = async function(manualIndex) {
const workTypeId = document.getElementById(`workType_${manualIndex}`).value;
const taskSelect = document.getElementById(`task_${manualIndex}`);
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(task => `<option value="${escapeHtml(String(task.task_id))}">${escapeHtml(task.task_name)}</option>`).join('')}
`;
} else {
taskSelect.disabled = true;
taskSelect.innerHTML = '<option value="">등록된 작업이 없습니다</option>';
}
} catch (error) {
console.error('작업 목록 로드 오류:', error);
taskSelect.disabled = true;
taskSelect.innerHTML = '<option value="">작업 로드 실패</option>';
}
};
/**
* 수동 입력 부적합 시간 토글
*/
window.toggleManualErrorType = function(manualIndex) {
const errorInput = document.getElementById(`errorHours_${manualIndex}`);
const errorTypeSelect = document.getElementById(`errorType_${manualIndex}`);
const errorTypeNone = document.getElementById(`errorTypeNone_${manualIndex}`);
const errorHours = parseFloat(errorInput.value) || 0;
if (errorHours > 0) {
errorTypeSelect.style.display = 'inline-block';
if (errorTypeNone) errorTypeNone.style.display = 'none';
} else {
errorTypeSelect.style.display = 'none';
if (errorTypeNone) errorTypeNone.style.display = 'inline';
}
};
/**
* 수동 입력용 작업장소 선택 모달 열기
*/
window.openWorkplaceMapForManual = async function(manualIndex) {
window.currentManualIndex = manualIndex;
// 변수 초기화
selectedWorkplace = null;
selectedWorkplaceName = null;
selectedWorkplaceCategory = null;
selectedWorkplaceCategoryName = null;
try {
// 작업장소 카테고리 로드
const categoriesResponse = await window.apiCall('/workplaces/categories');
const categories = categoriesResponse.success ? categoriesResponse.data : categoriesResponse;
// 작업장소 모달 표시
const modal = document.getElementById('workplaceModal');
const categoryList = document.getElementById('workplaceCategoryList');
categoryList.innerHTML = categories.map(cat => {
const safeId = parseInt(cat.category_id) || 0;
const safeName = escapeHtml(cat.category_name);
const safeImage = escapeHtml(cat.layout_image || '');
return `
<button type="button" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceCategory(${safeId}, "${safeName.replace(/"/g, '&quot;')}", "${safeImage.replace(/"/g, '&quot;')}")'>
<span style="margin-right: 0.5rem;">🏭</span>
${safeName}
</button>
`;
}).join('');
// 카테고리 선택 화면 표시
document.getElementById('categorySelectionArea').style.display = 'block';
document.getElementById('workplaceSelectionArea').style.display = 'none';
modal.style.display = 'flex';
} catch (error) {
console.error('작업장소 카테고리 로드 오류:', error);
showMessage('작업장소 목록을 불러오는 중 오류가 발생했습니다.', 'error');
}
};
/**
* 작업장소 카테고리 선택
*/
window.selectWorkplaceCategory = async function(categoryId, categoryName, layoutImage) {
selectedWorkplaceCategory = categoryId;
selectedWorkplaceCategoryName = categoryName;
try {
// 타이틀 업데이트
document.getElementById('selectedCategoryTitle').textContent = `${categoryName} - 작업장 선택`;
// 카테고리 화면 숨기고 작업장 선택 화면 표시
document.getElementById('categorySelectionArea').style.display = 'none';
document.getElementById('workplaceSelectionArea').style.display = 'block';
// 해당 카테고리의 작업장소 로드
const workplacesResponse = await window.apiCall(`/workplaces?category_id=${categoryId}`);
const workplaces = workplacesResponse.success ? workplacesResponse.data : workplacesResponse;
// 지도 또는 리스트 로드
if (layoutImage && layoutImage !== '') {
// 지도가 있는 경우 - 지도 영역 표시
await loadWorkplaceMap(categoryId, layoutImage, workplaces);
document.getElementById('layoutMapArea').style.display = 'block';
} else {
// 지도가 없는 경우 - 리스트만 표시
document.getElementById('layoutMapArea').style.display = 'none';
}
// 리스트 항상 표시
const workplaceListArea = document.getElementById('workplaceListArea');
workplaceListArea.innerHTML = workplaces.map(wp => {
const safeId = parseInt(wp.workplace_id) || 0;
const safeName = escapeHtml(wp.workplace_name);
return `
<button type="button" id="workplace-${safeId}" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceFromList(${safeId}, "${safeName.replace(/"/g, '&quot;')}")'>
<span style="margin-right: 0.5rem;">📍</span>
${safeName}
</button>
`;
}).join('');
} catch (error) {
console.error('작업장소 로드 오류:', error);
showMessage('작업장소를 불러오는 중 오류가 발생했습니다.', 'error');
}
};
/**
* 작업장소 지도 로드
*/
async function loadWorkplaceMap(categoryId, layoutImagePath, workplaces) {
try {
mapCanvas = document.getElementById('workplaceMapCanvas');
if (!mapCanvas) return;
mapCtx = mapCanvas.getContext('2d');
// 이미지 URL 생성
const baseUrl = window.API_BASE_URL || 'http://localhost:20005';
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
const fullImageUrl = layoutImagePath.startsWith('http')
? layoutImagePath
: `${apiBaseUrl}${layoutImagePath}`;
console.log('🖼️ 이미지 로드 시도:', fullImageUrl);
// 지도 영역 데이터 로드
const regionsResponse = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`);
if (regionsResponse && regionsResponse.success) {
mapRegions = regionsResponse.data || [];
} else {
mapRegions = [];
}
// 이미지 로드
mapImage = new Image();
mapImage.crossOrigin = 'anonymous';
mapImage.onload = function() {
// 캔버스 크기 설정 (최대 너비 800px)
const maxWidth = 800;
const scale = mapImage.width > maxWidth ? maxWidth / mapImage.width : 1;
mapCanvas.width = mapImage.width * scale;
mapCanvas.height = mapImage.height * scale;
// 이미지와 영역 그리기
drawWorkplaceMap();
// 클릭 이벤트 리스너 추가
mapCanvas.onclick = handleMapClick;
console.log(`✅ 작업장 지도 로드 완료: ${mapRegions.length}개 영역`);
};
mapImage.onerror = function() {
console.error('❌ 지도 이미지 로드 실패');
document.getElementById('layoutMapArea').style.display = 'none';
showMessage('지도를 불러올 수 없어 리스트로 표시합니다.', 'warning');
};
mapImage.src = fullImageUrl;
} catch (error) {
console.error('❌ 작업장 지도 로드 오류:', error);
document.getElementById('layoutMapArea').style.display = 'none';
}
}
/**
* 지도 그리기
*/
function drawWorkplaceMap() {
if (!mapCanvas || !mapCtx || !mapImage) return;
// 이미지 그리기
mapCtx.drawImage(mapImage, 0, 0, mapCanvas.width, mapCanvas.height);
// 각 영역 그리기
mapRegions.forEach((region) => {
// 퍼센트를 픽셀로 변환
const x1 = (region.x_start / 100) * mapCanvas.width;
const y1 = (region.y_start / 100) * mapCanvas.height;
const x2 = (region.x_end / 100) * mapCanvas.width;
const y2 = (region.y_end / 100) * mapCanvas.height;
const width = x2 - x1;
const height = y2 - y1;
// 선택된 영역인지 확인
const isSelected = region.workplace_id === selectedWorkplace;
// 영역 테두리
mapCtx.strokeStyle = isSelected ? '#3b82f6' : '#10b981';
mapCtx.lineWidth = isSelected ? 4 : 2;
mapCtx.strokeRect(x1, y1, width, height);
// 영역 배경 (반투명)
mapCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.25)' : 'rgba(16, 185, 129, 0.15)';
mapCtx.fillRect(x1, y1, width, height);
// 작업장 이름 표시
if (region.workplace_name) {
mapCtx.font = 'bold 14px sans-serif';
// 텍스트 배경
const textMetrics = mapCtx.measureText(region.workplace_name);
const textPadding = 6;
mapCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.95)' : 'rgba(16, 185, 129, 0.95)';
mapCtx.fillRect(x1 + 5, y1 + 5, textMetrics.width + textPadding * 2, 24);
// 텍스트
mapCtx.fillStyle = '#ffffff';
mapCtx.fillText(region.workplace_name, x1 + 5 + textPadding, y1 + 22);
}
});
}
/**
* 지도 클릭 이벤트 처리
*/
function handleMapClick(event) {
if (!mapCanvas || mapRegions.length === 0) return;
const rect = mapCanvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// 클릭한 위치에 있는 영역 찾기
for (let i = mapRegions.length - 1; i >= 0; i--) {
const region = mapRegions[i];
const x1 = (region.x_start / 100) * mapCanvas.width;
const y1 = (region.y_start / 100) * mapCanvas.height;
const x2 = (region.x_end / 100) * mapCanvas.width;
const y2 = (region.y_end / 100) * mapCanvas.height;
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
// 영역 클릭됨
selectWorkplaceFromList(region.workplace_id, region.workplace_name);
return;
}
}
}
/**
* 리스트에서 작업장소 선택
*/
window.selectWorkplaceFromList = function(workplaceId, workplaceName) {
selectedWorkplace = workplaceId;
selectedWorkplaceName = workplaceName;
// 지도 다시 그리기 (선택 효과 표시)
if (mapCanvas && mapCtx && mapImage) {
drawWorkplaceMap();
}
// 리스트 버튼 업데이트
document.querySelectorAll('[id^="workplace-"]').forEach(btn => {
if (btn.id === `workplace-${workplaceId}`) {
btn.classList.remove('btn-secondary');
btn.classList.add('btn-primary');
} else {
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
}
});
// 선택 완료 버튼 활성화
document.getElementById('confirmWorkplaceBtn').disabled = false;
};
/**
* 작업장소 선택 완료
*/
window.confirmWorkplaceSelection = function() {
const manualIndex = window.currentManualIndex;
if (!selectedWorkplace || !selectedWorkplaceCategory) {
showMessage('작업장소를 선택해주세요.', 'error');
return;
}
document.getElementById(`workplaceCategory_${manualIndex}`).value = selectedWorkplaceCategory;
document.getElementById(`workplace_${manualIndex}`).value = selectedWorkplace;
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: #059669; font-weight: 600;">
<span>✓</span>
<span>작업장소 선택됨</span>
</div>
<div style="font-size: 0.8rem; color: #111827; font-weight: 500;">
<div style="color: #6b7280; font-size: 0.7rem; margin-bottom: 2px;">🏭 ${escapeHtml(selectedWorkplaceCategoryName)}</div>
<div>📍 ${escapeHtml(selectedWorkplaceName)}</div>
</div>
`;
displayDiv.style.background = '#ecfdf5';
displayDiv.style.borderColor = '#10b981';
}
// 모달 닫기
closeWorkplaceModal();
showMessage('작업장소가 선택되었습니다.', 'success');
};
/**
* 작업장소 모달 닫기
*/
window.closeWorkplaceModal = function() {
document.getElementById('workplaceModal').style.display = 'none';
// 초기화
selectedWorkplace = null;
selectedWorkplaceName = null;
mapCanvas = null;
mapCtx = null;
mapImage = null;
mapRegions = [];
};
/**
* 수동 작업보고서 제출
*/
window.submitManualWorkReport = async function(manualIndex) {
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);
const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null;
// 필수 필드 검증
if (!workerId) {
showMessage('작업자를 선택해주세요.', 'error');
return;
}
if (!reportDate) {
showMessage('작업 날짜를 입력해주세요.', 'error');
return;
}
if (!projectId) {
showMessage('프로젝트를 선택해주세요.', 'error');
return;
}
if (!workTypeId) {
showMessage('공정을 선택해주세요.', 'error');
return;
}
if (!taskId) {
showMessage('작업을 선택해주세요.', 'error');
return;
}
if (!workplaceId) {
showMessage('작업장소를 선택해주세요.', 'error');
return;
}
if (!totalHours || totalHours <= 0) {
showMessage('작업시간을 입력해주세요.', 'error');
return;
}
if (errorHours > totalHours) {
showMessage('부적합 처리 시간은 총 작업시간을 초과할 수 없습니다.', 'error');
return;
}
// 부적합 원인 유효성 검사 (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);
if (invalidDefects.length > 0) {
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
return;
}
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
};
try {
const response = await window.apiCall('/daily-work-reports', 'POST', reportData);
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);
if (validDefects.length > 0) {
await window.apiCall(`/daily-work-reports/${response.data.workReport_ids[0]}/defects`, 'PUT', {
defects: validDefects
});
}
}
showSaveResultModal(
'success',
'작업보고서 제출 완료',
'작업보고서가 성공적으로 제출되었습니다.'
);
// 행 제거 (부적합 임시 데이터도 함께 삭제됨)
removeManualWorkRow(manualIndex);
// 목록 새로고침
await loadIncompleteTbms();
} catch (error) {
console.error('수동 작업보고서 제출 오류:', error);
showSaveResultModal('error', '제출 실패', error.message);
}
};
/**
* 날짜 포맷 함수
*/
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 신고 상태 라벨 반환
*/
function getStatusLabel(status) {
const labels = {
'reported': '신고됨',
'received': '접수됨',
'in_progress': '처리중',
'completed': '완료',
'closed': '종료'
};
return labels[status] || status || '-';
}
/**
* 작성 완료된 작업보고서 로드
*/
window.loadCompletedReports = async function() {
try {
const selectedDate = document.getElementById('completedReportDate').value;
if (!selectedDate) {
showMessage('날짜를 선택해주세요.', 'error');
return;
}
// 해당 날짜의 작업보고서 조회
const response = await window.apiCall(`/daily-work-reports?date=${selectedDate}`);
console.log('완료된 보고서 API 응답:', response);
// API 응답이 배열인지 객체인지 확인
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 : [];
}
renderCompletedReports(reports);
} catch (error) {
console.error('완료된 보고서 로드 오류:', error);
showMessage('작업보고서를 불러오는 중 오류가 발생했습니다.', 'error');
}
};
/**
* 완료된 보고서 목록 렌더링
*/
function renderCompletedReports(reports) {
const container = document.getElementById('completedReportsList');
if (!reports || reports.length === 0) {
container.innerHTML = '<p style="text-align: center; padding: 2rem; color: #9ca3af;">작성된 작업보고서가 없습니다.</p>';
return;
}
const html = reports.map(report => `
<div class="completed-report-card">
<div class="report-header">
<div>
<h4>${escapeHtml(report.worker_name || '작업자')}</h4>
${report.tbm_session_id ? '<span class="tbm-badge">TBM 연동</span>' : '<span class="manual-badge">수동 입력</span>'}
</div>
<span class="report-date">${escapeHtml(formatDate(report.report_date))}</span>
</div>
<div class="report-info">
<div class="info-row">
<span class="label">프로젝트:</span>
<span class="value">${escapeHtml(report.project_name || '-')}</span>
</div>
<div class="info-row">
<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">${parseFloat(report.total_hours || report.work_hours || 0)}시간</span>
</div>
${report.regular_hours !== undefined && report.regular_hours !== null ? `
<div class="info-row">
<span class="label">정규 시간:</span>
<span class="value">${parseFloat(report.regular_hours)}시간</span>
</div>
` : ''}
${report.error_hours && report.error_hours > 0 ? `
<div class="info-row">
<span class="label">부적합 처리:</span>
<span class="value" style="color: #dc2626;">${parseFloat(report.error_hours)}시간</span>
</div>
<div class="info-row">
<span class="label">부적합 원인:</span>
<span class="value">${escapeHtml(report.error_type_name || '-')}</span>
</div>
` : ''}
<div class="info-row">
<span class="label">작성자:</span>
<span class="value">${escapeHtml(report.created_by_name || '-')}</span>
</div>
${report.start_time && report.end_time ? `
<div class="info-row">
<span class="label">작업 시간:</span>
<span class="value">${escapeHtml(report.start_time)} ~ ${escapeHtml(report.end_time)}</span>
</div>
` : ''}
</div>
</div>
`).join('');
container.innerHTML = html;
}
// =================================================================
// 기존 함수들
// =================================================================
// 한국 시간 기준 오늘 날짜 가져오기
function getKoreaToday() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 현재 로그인한 사용자 정보 가져오기
function getCurrentUser() {
try {
const token = localStorage.getItem('token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
console.log('토큰에서 추출한 사용자 정보:', payload);
return payload;
}
} catch (error) {
console.log('토큰에서 사용자 정보 추출 실패:', error);
}
try {
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
if (userInfo) {
const parsed = JSON.parse(userInfo);
console.log('localStorage에서 가져온 사용자 정보:', parsed);
return parsed;
}
} catch (error) {
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
}
return null;
}
// 메시지 표시
function showMessage(message, type = 'info') {
const container = document.getElementById('message-container');
container.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type === 'success') {
setTimeout(() => {
hideMessage();
}, 5000);
}
}
function hideMessage() {
document.getElementById('message-container').innerHTML = '';
}
// 저장 결과 모달 표시
function showSaveResultModal(type, title, message, details = null) {
const modal = document.getElementById('saveResultModal');
const titleElement = document.getElementById('resultModalTitle');
const contentElement = document.getElementById('resultModalContent');
// 아이콘 설정
let icon = '';
switch (type) {
case 'success':
icon = '✅';
break;
case 'error':
icon = '❌';
break;
case 'warning':
icon = '⚠️';
break;
default:
icon = '';
}
// 모달 내용 구성
let content = `
<div class="result-icon ${type}">${icon}</div>
<h3 class="result-title ${type}">${title}</h3>
<p class="result-message">${message}</p>
`;
// 상세 정보가 있으면 추가
if (details) {
if (Array.isArray(details) && details.length > 0) {
content += `
<div class="result-details">
<h4>상세 정보:</h4>
<ul>
${details.map(detail => `<li>${detail}</li>`).join('')}
</ul>
</div>
`;
} else if (typeof details === 'string') {
content += `
<div class="result-details">
<p>${details}</p>
</div>
`;
}
}
titleElement.textContent = '저장 결과';
contentElement.innerHTML = content;
modal.style.display = 'flex';
// ESC 키로 닫기
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
closeSaveResultModal();
}
});
// 배경 클릭으로 닫기
modal.addEventListener('click', function (e) {
if (e.target === modal) {
closeSaveResultModal();
}
});
}
// 저장 결과 모달 닫기
function closeSaveResultModal() {
const modal = document.getElementById('saveResultModal');
modal.style.display = 'none';
// 이벤트 리스너 제거
document.removeEventListener('keydown', closeSaveResultModal);
}
// 전역에서 접근 가능하도록 window에 할당
window.closeSaveResultModal = closeSaveResultModal;
// 단계 이동
function goToStep(stepNumber) {
for (let i = 1; i <= 3; i++) {
const step = document.getElementById(`step${i}`);
if (step) {
step.classList.remove('active', 'completed');
if (i < stepNumber) {
step.classList.add('completed');
const stepNum = step.querySelector('.step-number');
if (stepNum) stepNum.classList.add('completed');
} else if (i === stepNumber) {
step.classList.add('active');
}
}
}
// 진행 단계 표시 업데이트
updateProgressSteps(stepNumber);
currentStep = stepNumber;
}
// 진행 단계 표시 업데이트
function updateProgressSteps(currentStepNumber) {
for (let i = 1; i <= 3; i++) {
const progressStep = document.getElementById(`progressStep${i}`);
if (progressStep) {
progressStep.classList.remove('active', 'completed');
if (i < currentStepNumber) {
progressStep.classList.add('completed');
} else if (i === currentStepNumber) {
progressStep.classList.add('active');
}
}
}
}
// 초기 데이터 로드 (통합 API 사용)
async function loadData() {
try {
showMessage('데이터를 불러오는 중...', 'loading');
console.log('🔗 통합 API 설정을 사용한 기본 데이터 로딩 시작...');
await loadWorkers();
await loadProjects();
await loadWorkTypes();
await loadWorkStatusTypes();
await loadErrorTypes();
console.log('로드된 작업자 수:', workers.length);
console.log('로드된 프로젝트 수:', projects.length);
console.log('작업 유형 수:', workTypes.length);
hideMessage();
} catch (error) {
console.error('데이터 로드 실패:', error);
showMessage('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
async function loadWorkers() {
try {
console.log('Workers API 호출 중... (통합 API 사용)');
// 생산팀 소속 작업자만 조회
const data = await window.apiCall(`/workers?limit=1000&department_id=1`);
const allWorkers = Array.isArray(data) ? data : (data.data || data.workers || []);
// 작업 보고서에 표시할 작업자만 필터링
// 퇴사자만 제외 (계정 여부와 무관하게 재직자는 모두 표시)
workers = allWorkers.filter(worker => {
const notResigned = worker.employment_status !== 'resigned';
return notResigned;
});
console.log(`✅ Workers 로드 성공: ${workers.length}명 (전체: ${allWorkers.length}명)`);
console.log(`📊 필터링 조건: employment_status≠resigned (퇴사자만 제외)`);
} catch (error) {
console.error('작업자 로딩 오류:', error);
throw error;
}
}
async function loadProjects() {
try {
console.log('Projects API 호출 중... (활성 프로젝트만)');
const data = await window.apiCall(`/projects/active/list`);
projects = Array.isArray(data) ? data : (data.data || data.projects || []);
console.log('✅ 활성 프로젝트 로드 성공:', projects.length);
} catch (error) {
console.error('프로젝트 로딩 오류:', error);
throw error;
}
}
async function loadWorkTypes() {
try {
const data = await window.apiCall(`/daily-work-reports/work-types`);
if (Array.isArray(data) && data.length > 0) {
workTypes = data;
console.log('✅ 작업 유형 API 사용 (통합 설정)');
return;
}
throw new Error('API 실패');
} catch (error) {
console.log('⚠️ 작업 유형 API 사용 불가, 기본값 사용');
workTypes = [
{ id: 1, name: 'Base' },
{ id: 2, name: 'Vessel' },
{ id: 3, name: 'Piping' }
];
}
}
async function loadWorkStatusTypes() {
try {
const data = await window.apiCall(`/daily-work-reports/work-status-types`);
if (Array.isArray(data) && data.length > 0) {
workStatusTypes = data;
console.log('✅ 업무 상태 유형 API 사용 (통합 설정)');
return;
}
throw new Error('API 실패');
} catch (error) {
console.log('⚠️ 업무 상태 유형 API 사용 불가, 기본값 사용');
workStatusTypes = [
{ id: 1, name: '정규' },
{ id: 2, name: '에러' }
];
}
}
async function loadErrorTypes() {
// 레거시 에러 유형 로드 (호환성)
try {
const data = await window.apiCall(`/daily-work-reports/error-types`);
if (Array.isArray(data) && data.length > 0) {
errorTypes = data;
}
} catch (error) {
errorTypes = [];
}
// 신고 카테고리 로드 (부적합 유형만)
try {
const catResponse = await window.apiCall('/work-issues/categories/type/nonconformity');
if (catResponse && catResponse.success && Array.isArray(catResponse.data)) {
issueCategories = catResponse.data;
console.log(`✅ 부적합 카테고리 ${issueCategories.length}개 로드`);
// 모든 아이템 로드
const itemResponse = await window.apiCall('/work-issues/items');
if (itemResponse && itemResponse.success && Array.isArray(itemResponse.data)) {
// 부적합 카테고리의 아이템만 필터링
const categoryIds = issueCategories.map(c => c.category_id);
issueItems = itemResponse.data.filter(item => categoryIds.includes(item.category_id));
console.log(`✅ 부적합 아이템 ${issueItems.length}개 로드`);
}
}
} catch (error) {
console.log('⚠️ 신고 카테고리 로드 실패:', error);
issueCategories = [];
issueItems = [];
}
}
// TBM 팀 구성 자동 불러오기
async function loadTbmTeamForDate(date) {
try {
console.log('🛠️ TBM 팀 구성 조회 중:', date);
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
if (response && response.success && response.data && response.data.length > 0) {
// 가장 최근 세션 선택 (진행중인 세션 우선)
const draftSessions = response.data.filter(s => s.status === 'draft');
const targetSession = draftSessions.length > 0 ? draftSessions[0] : response.data[0];
if (targetSession) {
// 팀 구성 조회
const teamRes = await window.apiCall(`/tbm/sessions/${targetSession.session_id}/team`);
if (teamRes && teamRes.success && teamRes.data) {
const teamWorkerIds = teamRes.data.map(m => m.worker_id);
console.log(`✅ TBM 팀 구성 로드 성공: ${teamWorkerIds.length}`);
return teamWorkerIds;
}
}
}
console.log(' 해당 날짜의 TBM 팀 구성이 없습니다.');
return [];
} catch (error) {
console.error('❌ TBM 팀 구성 조회 오류:', error);
return [];
}
}
// 작업자 그리드 생성
async function populateWorkerGrid() {
const grid = document.getElementById('workerGrid');
grid.innerHTML = '';
// 선택된 날짜의 TBM 팀 구성 불러오기
const reportDate = document.getElementById('reportDate').value;
let tbmWorkerIds = [];
if (reportDate) {
tbmWorkerIds = await loadTbmTeamForDate(reportDate);
}
// TBM 팀 구성이 있으면 안내 메시지 표시
if (tbmWorkerIds.length > 0) {
const infoDiv = document.createElement('div');
infoDiv.style.cssText = `
padding: 1rem;
background: #eff6ff;
border: 1px solid #3b82f6;
border-radius: 0.5rem;
margin-bottom: 1rem;
color: #1e40af;
font-size: 0.875rem;
`;
infoDiv.innerHTML = `
<strong>🛠️ TBM 팀 구성 자동 적용</strong><br>
오늘 TBM에서 구성된 팀원 ${tbmWorkerIds.length}명이 자동으로 선택되었습니다.
`;
grid.appendChild(infoDiv);
}
workers.forEach(worker => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'worker-card';
btn.textContent = worker.worker_name;
btn.dataset.id = worker.worker_id;
// TBM 팀 구성에 포함된 작업자는 자동 선택
if (tbmWorkerIds.includes(worker.worker_id)) {
btn.classList.add('selected');
selectedWorkers.add(worker.worker_id);
}
btn.addEventListener('click', () => {
toggleWorkerSelection(worker.worker_id, btn);
});
grid.appendChild(btn);
});
// 자동 선택된 작업자가 있으면 다음 단계 버튼 활성화
const nextBtn = document.getElementById('nextStep2');
if (nextBtn) {
nextBtn.disabled = selectedWorkers.size === 0;
}
}
// 작업자 선택 토글
function toggleWorkerSelection(workerId, btnElement) {
if (selectedWorkers.has(workerId)) {
selectedWorkers.delete(workerId);
btnElement.classList.remove('selected');
} else {
selectedWorkers.add(workerId);
btnElement.classList.add('selected');
}
const nextBtn = document.getElementById('nextStep2');
nextBtn.disabled = selectedWorkers.size === 0;
}
// 작업 항목 추가
function addWorkEntry() {
console.log('🔧 addWorkEntry 함수 호출됨');
const container = document.getElementById('workEntriesList');
console.log('🔧 컨테이너:', container);
workEntryCounter++;
console.log('🔧 작업 항목 카운터:', workEntryCounter);
const entryDiv = document.createElement('div');
entryDiv.className = 'work-entry';
entryDiv.dataset.id = workEntryCounter;
console.log('🔧 생성된 작업 항목 div:', entryDiv);
entryDiv.innerHTML = `
<div class="work-entry-header">
<div class="work-entry-title">작업 항목 #${workEntryCounter}</div>
<button type="button" class="remove-work-btn" onclick="event.stopPropagation(); removeWorkEntry(${workEntryCounter})" title="이 작업 삭제">
🗑️ 삭제
</button>
</div>
<div class="work-entry-grid">
<div class="form-field-group">
<div class="form-field-label">
<span class="form-field-icon">🏗️</span>
프로젝트
</div>
<select class="form-select project-select" required>
<option value="">프로젝트를 선택하세요</option>
${projects.map(p => `<option value="${escapeHtml(String(p.project_id))}">${escapeHtml(p.project_name)}</option>`).join('')}
</select>
</div>
<div class="form-field-group">
<div class="form-field-label">
<span class="form-field-icon">⚙️</span>
작업 유형
</div>
<select class="form-select work-type-select" required>
<option value="">작업 유형을 선택하세요</option>
${workTypes.map(wt => `<option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="work-entry-full">
<div class="form-field-group">
<div class="form-field-label">
<span class="form-field-icon">📊</span>
업무 상태
</div>
<select class="form-select work-status-select" required>
<option value="">업무 상태를 선택하세요</option>
${workStatusTypes.map(ws => `<option value="${escapeHtml(String(ws.id))}">${escapeHtml(ws.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="error-type-section work-entry-full">
<div class="form-field-label">
<span class="form-field-icon">⚠️</span>
에러 유형
</div>
<select class="form-select error-type-select">
<option value="">에러 유형을 선택하세요</option>
${errorTypes.map(et => `<option value="${et.id}">${et.name}</option>`).join('')}
</select>
</div>
<div class="time-input-section work-entry-full">
<div class="form-field-label">
<span class="form-field-icon">⏰</span>
작업 시간 (시간)
</div>
<input type="number" class="form-select time-input"
placeholder="작업 시간을 입력하세요"
min="0.25"
max="24"
step="0.25"
value="1.00"
required>
<div class="quick-time-buttons">
<button type="button" class="quick-time-btn" data-hours="0.5">30분</button>
<button type="button" class="quick-time-btn" data-hours="1">1시간</button>
<button type="button" class="quick-time-btn" data-hours="2">2시간</button>
<button type="button" class="quick-time-btn" data-hours="4">4시간</button>
<button type="button" class="quick-time-btn" data-hours="8">8시간</button>
</div>
</div>
`;
container.appendChild(entryDiv);
console.log('🔧 작업 항목이 컨테이너에 추가됨');
console.log('🔧 현재 컨테이너 내용:', container.innerHTML.length, '문자');
console.log('🔧 현재 .work-entry 개수:', container.querySelectorAll('.work-entry').length);
setupWorkEntryEvents(entryDiv);
console.log('🔧 이벤트 설정 완료');
}
// 작업 항목 이벤트 설정
function setupWorkEntryEvents(entryDiv) {
const timeInput = entryDiv.querySelector('.time-input');
const workStatusSelect = entryDiv.querySelector('.work-status-select');
const errorTypeSection = entryDiv.querySelector('.error-type-section');
const errorTypeSelect = entryDiv.querySelector('.error-type-select');
// 시간 입력 이벤트
timeInput.addEventListener('input', updateTotalHours);
// 빠른 시간 버튼 이벤트
entryDiv.querySelectorAll('.quick-time-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
timeInput.value = btn.dataset.hours;
updateTotalHours();
// 버튼 클릭 효과
btn.style.transform = 'scale(0.95)';
setTimeout(() => {
btn.style.transform = '';
}, 150);
});
});
// 업무 상태 변경 시 에러 유형 섹션 토글
workStatusSelect.addEventListener('change', (e) => {
const isError = e.target.value === '2'; // 에러 상태 ID가 2라고 가정
if (isError) {
errorTypeSection.classList.add('visible');
errorTypeSelect.required = true;
// 에러 상태일 때 시각적 피드백
errorTypeSection.style.animation = 'slideDown 0.4s ease-out';
} else {
errorTypeSection.classList.remove('visible');
errorTypeSelect.required = false;
errorTypeSelect.value = '';
}
});
// 폼 필드 포커스 효과
entryDiv.querySelectorAll('.form-field-group').forEach(group => {
const input = group.querySelector('select, input');
if (input) {
input.addEventListener('focus', () => {
group.classList.add('focused');
});
input.addEventListener('blur', () => {
group.classList.remove('focused');
});
}
});
}
// 작업 항목 제거
function removeWorkEntry(id) {
console.log('🗑️ removeWorkEntry 호출됨, id:', id);
const entry = document.querySelector(`.work-entry[data-id="${id}"]`);
console.log('🗑️ 찾은 entry:', entry);
if (entry) {
entry.remove();
updateTotalHours();
console.log('✅ 작업 항목 삭제 완료');
} else {
console.log('❌ 작업 항목을 찾을 수 없음');
}
}
// 총 시간 업데이트
function updateTotalHours() {
const timeInputs = document.querySelectorAll('.time-input');
let total = 0;
timeInputs.forEach(input => {
const value = parseFloat(input.value) || 0;
total += value;
});
const display = document.getElementById('totalHoursDisplay');
display.textContent = `총 작업시간: ${total}시간`;
if (total > 24) {
display.style.background = 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)';
display.textContent += ' ⚠️ 24시간 초과';
} else {
display.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
}
}
// 저장 함수 (통합 API 사용)
async function saveWorkReport() {
const reportDate = document.getElementById('reportDate').value;
if (!reportDate || selectedWorkers.size === 0) {
showSaveResultModal(
'error',
'입력 오류',
'날짜와 작업자를 선택해주세요.'
);
return;
}
const entries = document.querySelectorAll('.work-entry');
console.log('🔍 찾은 작업 항목들:', entries);
console.log('🔍 작업 항목 개수:', entries.length);
if (entries.length === 0) {
showSaveResultModal(
'error',
'작업 항목 없음',
'최소 하나의 작업을 추가해주세요.'
);
return;
}
const newWorkEntries = [];
console.log('🔍 작업 항목 수집 시작...');
for (const entry of entries) {
console.log('🔍 작업 항목 처리 중:', entry);
const projectSelect = entry.querySelector('.project-select');
const workTypeSelect = entry.querySelector('.work-type-select');
const workStatusSelect = entry.querySelector('.work-status-select');
const errorTypeSelect = entry.querySelector('.error-type-select');
const timeInput = entry.querySelector('.time-input');
console.log('🔍 선택된 요소들:', {
projectSelect,
workTypeSelect,
workStatusSelect,
errorTypeSelect,
timeInput
});
const projectId = projectSelect?.value;
const workTypeId = workTypeSelect?.value;
const workStatusId = workStatusSelect?.value;
const errorTypeId = errorTypeSelect?.value;
const workHours = timeInput?.value;
console.log('🔍 수집된 값들:', {
projectId,
workTypeId,
workStatusId,
errorTypeId,
workHours
});
if (!projectId || !workTypeId || !workStatusId || !workHours) {
showSaveResultModal(
'error',
'입력 오류',
'모든 작업 항목을 완성해주세요.'
);
return;
}
if (workStatusId === '2' && !errorTypeId) {
showSaveResultModal(
'error',
'입력 오류',
'에러 상태인 경우 에러 유형을 선택해주세요.'
);
return;
}
const workEntry = {
project_id: parseInt(projectId),
work_type_id: parseInt(workTypeId),
work_status_id: parseInt(workStatusId),
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
work_hours: parseFloat(workHours)
};
console.log('🔍 생성된 작업 항목:', workEntry);
console.log('🔍 작업 항목 상세:', {
project_id: workEntry.project_id,
work_type_id: workEntry.work_type_id,
work_status_id: workEntry.work_status_id,
error_type_id: workEntry.error_type_id,
work_hours: workEntry.work_hours
});
newWorkEntries.push(workEntry);
}
console.log('🔍 최종 수집된 작업 항목들:', newWorkEntries);
console.log('🔍 총 작업 항목 개수:', newWorkEntries.length);
try {
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.textContent = '💾 저장 중...';
const currentUser = getCurrentUser();
let totalSaved = 0;
let totalFailed = 0;
const failureDetails = [];
for (const workerId of selectedWorkers) {
const workerName = workers.find(w => w.worker_id == workerId)?.worker_name || '알 수 없음';
// 서버가 기대하는 work_entries 배열 형태로 전송
const requestData = {
report_date: reportDate,
worker_id: parseInt(workerId),
work_entries: newWorkEntries.map(entry => ({
project_id: entry.project_id,
task_id: entry.work_type_id, // 서버에서 task_id로 기대
work_hours: entry.work_hours,
work_status_id: entry.work_status_id,
error_type_id: entry.error_type_id
})),
created_by: currentUser?.user_id || currentUser?.id
};
console.log('🔄 배열 형태로 전송:', requestData);
console.log('🔄 work_entries:', requestData.work_entries);
console.log('🔄 work_entries[0] 상세:', requestData.work_entries[0]);
console.log('🔄 전송 데이터 JSON:', JSON.stringify(requestData, null, 2));
try {
const result = await window.apiCall(`/daily-work-reports`, 'POST', requestData);
console.log('✅ 저장 성공:', result);
totalSaved++;
} catch (error) {
console.error('❌ 저장 실패:', error);
totalFailed++;
failureDetails.push(`${workerName}: ${error.message}`);
}
}
// 결과 모달 표시
if (totalSaved > 0 && totalFailed === 0) {
showSaveResultModal(
'success',
'저장 완료!',
`${totalSaved}명의 작업보고서가 성공적으로 저장되었습니다.`
);
} else if (totalSaved > 0 && totalFailed > 0) {
showSaveResultModal(
'warning',
'부분 저장 완료',
`${totalSaved}명은 성공했지만 ${totalFailed}명은 실패했습니다.`,
failureDetails
);
} else {
showSaveResultModal(
'error',
'저장 실패',
'모든 작업보고서 저장이 실패했습니다.',
failureDetails
);
}
if (totalSaved > 0) {
setTimeout(() => {
refreshTodayWorkers();
resetForm();
}, 2000);
}
} catch (error) {
console.error('저장 오류:', error);
showSaveResultModal(
'error',
'저장 오류',
'저장 중 예기치 못한 오류가 발생했습니다.',
[error.message]
);
} finally {
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = false;
submitBtn.textContent = '💾 작업보고서 저장';
}
}
// 폼 초기화
function resetForm() {
goToStep(1);
selectedWorkers.clear();
document.querySelectorAll('.worker-card.selected').forEach(btn => {
btn.classList.remove('selected');
});
const container = document.getElementById('workEntriesList');
container.innerHTML = '';
workEntryCounter = 0;
updateTotalHours();
document.getElementById('nextStep2').disabled = true;
}
// 당일 작업자 현황 로드 (본인 입력분만) - 통합 API 사용
async function loadTodayWorkers() {
const section = document.getElementById('dailyWorkersSection');
const content = document.getElementById('dailyWorkersContent');
if (!section || !content) {
console.log('당일 현황 섹션이 HTML에 없습니다.');
return;
}
try {
const today = getKoreaToday();
const currentUser = getCurrentUser();
content.innerHTML = '<div class="loading-spinner">📊 내가 입력한 오늘의 작업 현황을 불러오는 중... (통합 API)</div>';
section.style.display = 'block';
// 본인이 입력한 데이터만 조회 (통합 API 사용)
let queryParams = `date=${today}`;
if (currentUser?.user_id) {
queryParams += `&created_by=${currentUser.user_id}`;
} else if (currentUser?.id) {
queryParams += `&created_by=${currentUser.id}`;
}
console.log(`🔒 본인 입력분만 조회 (통합 API): ${API}/daily-work-reports?${queryParams}`);
const rawData = await window.apiCall(`/daily-work-reports?${queryParams}`);
console.log('📊 당일 작업 데이터 (통합 API):', rawData);
let data = [];
if (Array.isArray(rawData)) {
data = rawData;
} else if (rawData?.data) {
data = rawData.data;
}
displayMyDailyWorkers(data, today);
} catch (error) {
console.error('당일 작업자 로드 오류:', error);
content.innerHTML = `
<div class="no-data-message">
❌ 오늘의 작업 현황을 불러올 수 없습니다.<br>
<small>${error.message}</small>
</div>
`;
}
}
// 본인 입력 작업자 현황 표시 (수정/삭제 기능 포함)
function displayMyDailyWorkers(data, date) {
const content = document.getElementById('dailyWorkersContent');
if (!Array.isArray(data) || data.length === 0) {
content.innerHTML = `
<div class="no-data-message">
📝 내가 오늘(${date}) 입력한 작업이 없습니다.<br>
<small>새로운 작업을 추가해보세요!</small>
</div>
`;
return;
}
// 작업자별로 데이터 그룹화
const workerGroups = {};
data.forEach(work => {
const workerName = work.worker_name || '미지정';
if (!workerGroups[workerName]) {
workerGroups[workerName] = [];
}
workerGroups[workerName].push(work);
});
const totalWorkers = Object.keys(workerGroups).length;
const totalWorks = data.length;
const headerHtml = `
<div class="daily-workers-header">
<h4>📊 내가 입력한 오늘(${escapeHtml(date)}) 작업 현황 - 총 ${parseInt(totalWorkers) || 0}명, ${parseInt(totalWorks) || 0}개 작업</h4>
<button class="refresh-btn" onclick="refreshTodayWorkers()">
🔄 새로고침
</button>
</div>
`;
const workersHtml = Object.entries(workerGroups).map(([workerName, works]) => {
const totalHours = works.reduce((sum, work) => {
return sum + parseFloat(work.work_hours || 0);
}, 0);
// 개별 작업 항목들 (수정/삭제 버튼 포함)
const individualWorksHtml = works.map((work) => {
const projectName = escapeHtml(work.project_name || '미지정');
const workTypeName = escapeHtml(work.work_type_name || '미지정');
const workStatusName = escapeHtml(work.work_status_name || '미지정');
const workHours = parseFloat(work.work_hours || 0);
const errorTypeName = work.error_type_name ? escapeHtml(work.error_type_name) : null;
const workId = parseInt(work.id) || 0;
return `
<div class="individual-work-item">
<div class="work-details-grid">
<div class="detail-item">
<div class="detail-label">🏗️ 프로젝트</div>
<div class="detail-value">${projectName}</div>
</div>
<div class="detail-item">
<div class="detail-label">⚙️ 작업종류</div>
<div class="detail-value">${workTypeName}</div>
</div>
<div class="detail-item">
<div class="detail-label">📊 작업상태</div>
<div class="detail-value">${workStatusName}</div>
</div>
<div class="detail-item">
<div class="detail-label">⏰ 작업시간</div>
<div class="detail-value">${workHours}시간</div>
</div>
${errorTypeName ? `
<div class="detail-item">
<div class="detail-label">❌ 에러유형</div>
<div class="detail-value">${errorTypeName}</div>
</div>
` : ''}
</div>
<div class="action-buttons">
<button class="edit-btn" onclick="editWorkItem('${workId}')">
✏️ 수정
</button>
<button class="delete-btn" onclick="deleteWorkItem('${workId}')">
🗑️ 삭제
</button>
</div>
</div>
`;
}).join('');
return `
<div class="worker-status-item">
<div class="worker-header">
<div class="worker-name">👤 ${escapeHtml(workerName)}</div>
<div class="worker-total-hours">총 ${parseFloat(totalHours)}시간</div>
</div>
<div class="individual-works-container">
${individualWorksHtml}
</div>
</div>
`;
}).join('');
content.innerHTML = headerHtml + '<div class="worker-status-grid">' + workersHtml + '</div>';
}
// 작업 항목 수정 함수 (통합 API 사용)
async function editWorkItem(workId) {
try {
console.log('수정할 작업 ID:', workId);
// 1. 기존 데이터 조회 (통합 API 사용)
showMessage('작업 정보를 불러오는 중... (통합 API)', 'loading');
const workData = await window.apiCall(`/daily-work-reports/${workId}`);
console.log('수정할 작업 데이터 (통합 API):', workData);
// 2. 수정 모달 표시
showEditModal(workData);
hideMessage();
} catch (error) {
console.error('작업 정보 조회 오류:', error);
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
}
}
// 수정 모달 표시
function showEditModal(workData) {
editingWorkId = workData.id;
const modalHtml = `
<div class="edit-modal" id="editModal">
<div class="edit-modal-content">
<div class="edit-modal-header">
<h3>✏️ 작업 수정</h3>
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
</div>
<div class="edit-modal-body">
<div class="edit-form-group">
<label>🏗️ 프로젝트</label>
<select class="edit-select" id="editProject">
<option value="">프로젝트 선택</option>
${projects.map(p => `
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
${p.project_name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>⚙️ 작업 유형</label>
<select class="edit-select" id="editWorkType">
<option value="">작업 유형 선택</option>
${workTypes.map(wt => `
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
${wt.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>📊 업무 상태</label>
<select class="edit-select" id="editWorkStatus">
<option value="">업무 상태 선택</option>
${workStatusTypes.map(ws => `
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
${ws.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
<label>❌ 에러 유형</label>
<select class="edit-select" id="editErrorType">
<option value="">에러 유형 선택</option>
${errorTypes.map(et => `
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
${et.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>⏰ 작업 시간</label>
<input type="number" class="edit-input" id="editWorkHours"
value="${workData.work_hours}"
min="0" max="24" step="0.5">
</div>
</div>
<div class="edit-modal-footer">
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
<button class="btn btn-success" onclick="saveEditedWork()">💾 저장</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 업무 상태 변경 이벤트
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
if (e.target.value === '2') {
errorTypeGroup.style.display = 'block';
} else {
errorTypeGroup.style.display = 'none';
}
});
}
// 수정 모달 닫기
function closeEditModal() {
const modal = document.getElementById('editModal');
if (modal) {
modal.remove();
}
editingWorkId = null;
}
// 수정된 작업 저장 (통합 API 사용)
async function saveEditedWork() {
try {
const projectId = document.getElementById('editProject').value;
const workTypeId = document.getElementById('editWorkType').value;
const workStatusId = document.getElementById('editWorkStatus').value;
const errorTypeId = document.getElementById('editErrorType').value;
const workHours = document.getElementById('editWorkHours').value;
if (!projectId || !workTypeId || !workStatusId || !workHours) {
showMessage('모든 필수 항목을 입력해주세요.', 'error');
return;
}
if (workStatusId === '2' && !errorTypeId) {
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
return;
}
const updateData = {
project_id: parseInt(projectId),
work_type_id: parseInt(workTypeId),
work_status_id: parseInt(workStatusId),
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
work_hours: parseFloat(workHours)
};
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
const result = await window.apiCall(`/daily-work-reports/${editingWorkId}`, {
method: 'PUT',
body: JSON.stringify(updateData)
});
console.log('✅ 수정 성공 (통합 API):', result);
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
closeEditModal();
refreshTodayWorkers();
} catch (error) {
console.error('❌ 수정 실패:', error);
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// 작업 항목 삭제 함수 (통합 API 사용)
async function deleteWorkItem(workId) {
if (!confirm('정말로 이 작업을 삭제하시겠습니까?\n삭제된 작업은 복구할 수 없습니다.')) {
return;
}
try {
console.log('삭제할 작업 ID:', workId);
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
// 개별 항목 삭제 API 호출 (본인 작성분만 삭제 가능) - 통합 API 사용
const result = await window.apiCall(`/daily-work-reports/my-entry/${workId}`, {
method: 'DELETE'
});
console.log('✅ 삭제 성공 (통합 API):', result);
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
// 화면 새로고침
refreshTodayWorkers();
} catch (error) {
console.error('❌ 삭제 실패:', error);
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// 오늘 현황 새로고침
function refreshTodayWorkers() {
loadTodayWorkers();
}
// 이벤트 리스너 설정 (이제 테이블 기반 UI를 사용하므로 별도 리스너 불필요)
function setupEventListeners() {
// 기존 단계별 입력 UI 제거됨
// 모든 이벤트는 onclick 핸들러로 직접 처리
}
// 초기화
async function init() {
try {
const token = localStorage.getItem('token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
localStorage.removeItem('token');
setTimeout(() => {
window.location.href = '/';
}, 2000);
return;
}
await loadData();
setupEventListeners();
// TBM 작업 목록 로드 (기본 탭)
await loadIncompleteTbms();
console.log('✅ 시스템 초기화 완료 (통합 API 설정 적용)');
} catch (error) {
console.error('초기화 오류:', error);
showMessage('초기화 중 오류가 발생했습니다.', 'error');
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', init);
// 전역 함수로 노출
window.removeWorkEntry = removeWorkEntry;
window.refreshTodayWorkers = refreshTodayWorkers;
window.editWorkItem = editWorkItem;
window.deleteWorkItem = deleteWorkItem;
window.closeEditModal = closeEditModal;
window.saveEditedWork = saveEditedWork;
// =================================================================
// 시간 선택 팝오버 관련 함수
// =================================================================
/**
* 시간 포맷팅 함수
*/
function formatHours(hours) {
const h = Math.floor(hours);
const m = (hours % 1) * 60;
if (m === 0) return `${h}시간`;
return `${h}시간 ${m}`;
}
/**
* 시간 선택 팝오버 열기
*/
window.openTimePicker = function(index, type) {
currentEditingField = { index, type };
// 현재 값 가져오기
const inputId = type === 'total' ? `totalHours_${index}` : `errorHours_${index}`;
const hiddenInput = document.getElementById(inputId);
currentTimeValue = parseFloat(hiddenInput?.value) || 0;
// 팝오버 표시
const overlay = document.getElementById('timePickerOverlay');
const title = document.getElementById('timePickerTitle');
title.textContent = type === 'total' ? '작업시간 선택' : '부적합 시간 선택';
updateTimeDisplay();
overlay.style.display = 'flex';
// ESC 키로 닫기
document.addEventListener('keydown', handleEscapeKey);
};
/**
* ESC 키 핸들러
*/
function handleEscapeKey(e) {
if (e.key === 'Escape') {
closeTimePicker();
}
}
/**
* 시간 값 설정
*/
window.setTimeValue = function(hours) {
currentTimeValue = hours;
updateTimeDisplay();
};
/**
* 시간 조정 (±30분)
*/
window.adjustTime = function(delta) {
currentTimeValue = Math.max(0, Math.min(24, currentTimeValue + delta));
updateTimeDisplay();
};
/**
* 시간 표시 업데이트
*/
function updateTimeDisplay() {
const display = document.getElementById('currentTimeDisplay');
if (display) {
display.textContent = formatHours(currentTimeValue);
}
}
/**
* 시간 선택 확인
*/
window.confirmTimeSelection = function() {
if (!currentEditingField) return;
const { index, type, defectIndex, issueReportId } = currentEditingField;
// 이슈 기반 부적합 시간 선택인 경우
if (type === 'issueDefect') {
if (tempDefects[index] && tempDefects[index][defectIndex] !== undefined) {
tempDefects[index][defectIndex].defect_hours = currentTimeValue;
// 시간 표시 업데이트
const timeDisplay = document.getElementById(`issueDefectTime_${index}_${issueReportId}`);
if (timeDisplay) {
timeDisplay.textContent = currentTimeValue;
}
// 요약 및 hidden 필드 업데이트
updateDefectSummary(index);
updateHiddenDefectFields(index);
}
closeTimePicker();
return;
}
// 레거시 부적합 시간 선택인 경우
if (type === 'defect') {
if (tempDefects[index] && tempDefects[index][defectIndex] !== undefined) {
tempDefects[index][defectIndex].defect_hours = currentTimeValue;
// 시간 표시 업데이트
const timeDisplay = document.getElementById(`defectTime_${index}_${defectIndex}`);
if (timeDisplay) {
timeDisplay.textContent = currentTimeValue;
}
// 요약 및 hidden 필드 업데이트
updateDefectSummary(index);
updateHiddenDefectFields(index);
}
closeTimePicker();
return;
}
// 기존 total/error 시간 선택
const inputId = type === 'total' ? `totalHours_${index}` : `errorHours_${index}`;
const displayId = type === 'total' ? `totalHoursDisplay_${index}` : `errorHoursDisplay_${index}`;
// hidden input 값 설정
const hiddenInput = document.getElementById(inputId);
if (hiddenInput) {
hiddenInput.value = currentTimeValue;
}
// 표시 영역 업데이트
const displayDiv = document.getElementById(displayId);
if (displayDiv) {
displayDiv.textContent = formatHours(currentTimeValue);
displayDiv.classList.remove('placeholder');
displayDiv.classList.add('has-value');
}
// 부적합 시간 입력 시 에러 타입 토글 (기존 방식 - 이제 사용안함)
if (type === 'error') {
if (index.toString().startsWith('manual_')) {
toggleManualErrorType(index);
} else {
calculateRegularHours(index);
}
}
closeTimePicker();
};
/**
* 시간 선택 팝오버 닫기
*/
window.closeTimePicker = function() {
const overlay = document.getElementById('timePickerOverlay');
if (overlay) {
overlay.style.display = 'none';
}
currentEditingField = null;
currentTimeValue = 0;
// ESC 키 리스너 제거
document.removeEventListener('keydown', handleEscapeKey);
};
// =================================================================
// 부적합 원인 관리 (인라인 방식)
// =================================================================
/**
* 부적합 영역 토글
* - 신고된 이슈 목록 표시
*/
window.toggleDefectArea = function(index) {
const defectRow = document.getElementById(`defectRow_${index}`);
if (!defectRow) return;
const isVisible = defectRow.style.display !== 'none';
if (isVisible) {
// 숨기기
defectRow.style.display = 'none';
} else {
// 보이기 - 부적합 원인이 없으면 자동으로 하나 추가
if (!tempDefects[index] || tempDefects[index].length === 0) {
tempDefects[index] = [{
issue_report_id: null,
category_id: null,
item_id: null,
error_type_id: '', // 레거시 호환
defect_hours: 0,
note: ''
}];
}
renderInlineDefectList(index);
defectRow.style.display = '';
}
};
/**
* 인라인 부적합 목록 렌더링
* - 해당 날짜에 신고된 이슈 목록을 표시
* - 이슈 선택 → 시간 입력 방식
*/
function renderInlineDefectList(index) {
const listContainer = document.getElementById(`defectList_${index}`);
if (!listContainer) return;
// 해당 TBM의 날짜 가져오기
const tbm = incompleteTbms[index];
const dateStr = tbm ? formatDateForApi(tbm.session_date) : null;
const issues = dateStr ? (dailyIssuesCache[dateStr] || []) : [];
// 작업장소 정보 가져오기
const workerWorkplaceId = tbm?.workplace_id;
const workerWorkplaceName = tbm?.workplace_name;
// 부적합 유형 + 작업장소 일치하는 것만 필터링
const nonconformityIssues = issues.filter(i => {
// 부적합 유형만
if (i.category_type !== 'nonconformity') return false;
// 작업장소 매칭 (workplace_id 우선, 없으면 이름 비교)
if (workerWorkplaceId && i.workplace_id) {
return i.workplace_id === workerWorkplaceId;
}
if (workerWorkplaceName && (i.workplace_name || i.custom_location)) {
const issueLocation = i.workplace_name || i.custom_location || '';
return issueLocation.includes(workerWorkplaceName) || workerWorkplaceName.includes(issueLocation);
}
// 작업장소 정보가 없으면 포함하지 않음
return false;
});
const defects = tempDefects[index] || [];
console.log(`📝 [renderInlineDefectList] index=${index}, 부적합 수=${defects.length}`, defects);
// 이슈가 있으면 이슈 선택 UI, 없으면 레거시 UI
if (nonconformityIssues.length > 0) {
// 이슈 선택 방식 UI
let html = `
<div class="defect-issue-section">
<div class="defect-issue-header">
<span class="defect-issue-title">📋 ${escapeHtml(workerWorkplaceName || '작업장소')} 관련 부적합</span>
<span class="defect-issue-count">${parseInt(nonconformityIssues.length) || 0}건</span>
</div>
<div class="defect-issue-list">
`;
nonconformityIssues.forEach(issue => {
// 이 이슈가 이미 선택되었는지 확인
const existingDefect = defects.find(d => d.issue_report_id == issue.report_id);
const isSelected = !!existingDefect;
const defectHours = existingDefect?.defect_hours || 0;
// 아이템명과 추가설명 조합
let itemText = issue.issue_item_name || '';
if (issue.additional_description) {
itemText = itemText ? `${itemText} - ${issue.additional_description}` : issue.additional_description;
}
const safeReportId = parseInt(issue.report_id) || 0;
html += `
<div class="defect-issue-item ${isSelected ? 'selected' : ''}" data-issue-id="${safeReportId}">
<div class="defect-issue-checkbox">
<input type="checkbox"
id="issueCheck_${index}_${safeReportId}"
${isSelected ? 'checked' : ''}
onchange="toggleIssueDefect('${index}', ${safeReportId}, this.checked)">
</div>
<div class="defect-issue-info">
<span class="defect-issue-category">${escapeHtml(issue.issue_category_name || '부적합')}</span>
<span class="defect-issue-item-name">${escapeHtml(itemText || '-')}</span>
<span class="defect-issue-location">${escapeHtml(issue.workplace_name || issue.custom_location || '')}</span>
</div>
<div class="defect-issue-time ${isSelected ? 'active' : ''}">
<div class="defect-time-input ${isSelected ? '' : 'disabled'}"
onclick="${isSelected ? `openIssueDefectTimePicker('${index}', ${safeReportId})` : ''}">
<span class="defect-time-value" id="issueDefectTime_${index}_${safeReportId}">${parseFloat(defectHours) || 0}</span>
<span class="defect-time-unit">시간</span>
</div>
</div>
</div>
`;
});
html += `
</div>
</div>
`;
// 레거시 방식도 추가 (기타 부적합 추가 버튼)
html += `
<div class="defect-legacy-section">
<div class="defect-legacy-list" id="legacyDefectList_${index}">
${renderLegacyDefects(index, defects)}
</div>
<div class="defect-action-buttons">
<button type="button" class="btn-add-defect-inline" onclick="addLegacyDefect('${index}')">
+ 부적합 추가
</button>
<button type="button" class="btn-save-defects" onclick="saveDefectsConfirm('${index}')" id="saveDefectsBtn_${index}">
저장
</button>
</div>
<div class="defect-save-message" id="defectSaveMessage_${index}" style="display: none;">
<span class="save-message-text">부적합 내용이 저장되었습니다.</span>
</div>
</div>
`;
listContainer.innerHTML = html;
} else {
// 이슈가 없으면 레거시 UI (error_types 선택)
const noIssueMsg = workerWorkplaceName
? `${escapeHtml(workerWorkplaceName)}에 신고된 부적합이 없습니다.`
: '신고된 부적합이 없습니다.';
listContainer.innerHTML = `
<div class="defect-no-issues">
<span class="no-issues-text">${noIssueMsg}</span>
</div>
<div class="defect-legacy-list" id="legacyDefectList_${index}">
${renderLegacyDefects(index, defects)}
</div>
<div class="defect-action-buttons">
<button type="button" class="btn-add-defect-inline" onclick="addLegacyDefect('${index}')">
+ 부적합 추가
</button>
<button type="button" class="btn-save-defects" onclick="saveDefectsConfirm('${index}')" id="saveDefectsBtn_${index}">
저장
</button>
</div>
<div class="defect-save-message" id="defectSaveMessage_${index}" style="display: none;">
<span class="save-message-text">부적합 내용이 저장되었습니다.</span>
</div>
`;
}
updateDefectSummary(index);
}
/**
* 레거시 부적합 렌더링 (저장된 항목 + 입력 중인 항목 분리)
*/
function renderLegacyDefects(index, defects) {
// issue_report_id가 없는 직접 입력 부적합
const legacyDefects = defects.filter(d => !d.issue_report_id);
if (legacyDefects.length === 0) return '';
// 저장된 항목과 입력 중인 항목 분리
const savedDefects = legacyDefects.filter(d => d._saved);
const editingDefects = legacyDefects.filter(d => !d._saved);
let html = '';
// 저장된 부적합 표시 (위쪽)
if (savedDefects.length > 0) {
html += `<div class="defect-saved-section">
<div class="defect-saved-header">
<span class="defect-saved-title">저장된 부적합</span>
<span class="defect-saved-count">${savedDefects.length}건</span>
</div>
<div class="defect-saved-list">`;
savedDefects.forEach(defect => {
const defectIndex = defects.indexOf(defect);
const categoryName = defect.category_id
? (issueCategories.find(c => c.category_id == defect.category_id)?.category_name || '미분류')
: '미분류';
const itemName = defect.item_id
? (issueItems.find(i => i.item_id == defect.item_id)?.item_name || '')
: '';
const displayText = itemName
? `${categoryName}${itemName}`
: categoryName;
html += `
<div class="defect-saved-item" data-defect-index="${defectIndex}">
<div class="defect-saved-info">
<span class="defect-saved-category">${categoryName}</span>
<span class="defect-saved-detail">${itemName}${defect.note ? ' - ' + defect.note : ''}</span>
</div>
<div class="defect-saved-actions">
<span class="defect-saved-hours">${defect.defect_hours || 0}시간</span>
<button type="button" class="btn-edit-defect" onclick="editSavedDefect('${index}', ${defectIndex})">수정</button>
<button type="button" class="btn-delete-defect" onclick="deleteSavedDefect('${index}', ${defectIndex})">삭제</button>
</div>
</div>
`;
});
html += `</div></div>`;
}
// 입력 중인 부적합 표시 (아래쪽)
if (editingDefects.length > 0) {
html += `<div class="defect-editing-section">`;
editingDefects.forEach(defect => {
const defectIndex = defects.indexOf(defect);
html += renderDefectInputForm(index, defect, defectIndex);
});
html += `</div>`;
}
return html;
}
/**
* 부적합 입력 폼 렌더링 (단일 항목)
*/
function renderDefectInputForm(index, defect, defectIndex) {
// 대분류 (카테고리) 옵션
const categoryOptions = issueCategories.map(cat =>
`<option value="${cat.category_id}" ${defect.category_id == cat.category_id ? 'selected' : ''}>${cat.category_name}</option>`
).join('');
// 소분류 (아이템) 옵션 - 선택된 카테고리의 아이템만
const filteredItems = defect.category_id
? issueItems.filter(item => item.category_id == defect.category_id)
: [];
const itemOptions = filteredItems.map(item =>
`<option value="${item.item_id}" ${defect.item_id == item.item_id ? 'selected' : ''}>${item.item_name}</option>`
).join('');
// 카테고리명/아이템명 표시 (미리보기용)
const categoryName = defect.category_id
? (issueCategories.find(c => c.category_id == defect.category_id)?.category_name || '')
: '';
const itemName = defect.item_id
? (issueItems.find(i => i.item_id == defect.item_id)?.item_name || '')
: '';
return `
<div class="defect-cascading-item" data-defect-index="${defectIndex}">
<div class="defect-cascading-row">
<!-- 대분류 (카테고리) -->
<div class="defect-field">
<label class="defect-field-label">대분류</label>
<div class="defect-select-wrapper">
<select class="defect-select defect-category-select"
id="defectCategory_${index}_${defectIndex}"
onchange="onDefectCategoryChange('${index}', ${defectIndex}, this.value)">
<option value="">선택</option>
${categoryOptions}
<option value="__new__">+ 새 대분류 추가</option>
</select>
</div>
</div>
<!-- 소분류 (아이템) -->
<div class="defect-field">
<label class="defect-field-label">소분류</label>
<div class="defect-select-wrapper">
<select class="defect-select defect-item-select"
id="defectItem_${index}_${defectIndex}"
onchange="onDefectItemChange('${index}', ${defectIndex}, this.value)"
${!defect.category_id ? 'disabled' : ''}>
<option value="">선택</option>
${itemOptions}
${defect.category_id ? '<option value="__new__">+ 새 소분류 추가</option>' : ''}
</select>
</div>
</div>
<!-- 시간 -->
<div class="defect-field defect-field-time">
<label class="defect-field-label">시간</label>
<div class="defect-time-input"
onclick="openDefectTimePicker('${index}', ${defectIndex})">
<span class="defect-time-value" id="defectTime_${index}_${defectIndex}">${defect.defect_hours || 0}</span>
<span class="defect-time-unit">시간</span>
</div>
</div>
<!-- 취소 버튼 -->
<button type="button" class="btn-remove-defect" onclick="cancelDefectEdit('${index}', ${defectIndex})">×</button>
</div>
<!-- 추가내용 입력 -->
<div class="defect-note-row">
<input type="text" class="defect-note-input"
id="defectNote_${index}_${defectIndex}"
placeholder="추가 설명 (선택사항)"
value="${defect.note || ''}"
onchange="updateInlineDefect('${index}', ${defectIndex}, 'note', this.value)">
</div>
<!-- 선택 미리보기 -->
${(categoryName || itemName) ? `
<div class="defect-preview">
<span class="defect-preview-text">
${categoryName}${itemName ? ' → ' + itemName : ''}${defect.note ? ' → ' + defect.note : ''}
</span>
</div>
` : ''}
</div>
`;
}
/**
* 대분류 선택 변경
*/
window.onDefectCategoryChange = async function(index, defectIndex, value) {
if (!tempDefects[index] || !tempDefects[index][defectIndex]) return;
const defect = tempDefects[index][defectIndex];
if (value === '__new__') {
// 새 카테고리 추가 모달
const newName = prompt('새 대분류(카테고리) 이름을 입력하세요:');
if (newName && newName.trim()) {
try {
const response = await window.apiCall('/work-issues/categories', 'POST', {
category_name: newName.trim(),
category_type: 'nonconformity',
severity: 'medium'
});
if (response.success) {
// 카테고리 목록 새로고침
await loadErrorTypes();
// 새로 생성된 카테고리 선택
const newCat = issueCategories.find(c => c.category_name === newName.trim());
if (newCat) {
defect.category_id = newCat.category_id;
defect.item_id = null;
}
} else {
alert('카테고리 추가 실패: ' + (response.error || '알 수 없는 오류'));
defect.category_id = null;
}
} catch (e) {
alert('카테고리 추가 중 오류: ' + e.message);
defect.category_id = null;
}
} else {
// 취소 시 이전 값 유지
defect.category_id = defect.category_id || null;
}
} else if (value) {
defect.category_id = parseInt(value);
defect.item_id = null; // 카테고리 변경 시 소분류 초기화
} else {
defect.category_id = null;
defect.item_id = null;
}
renderInlineDefectList(index);
updateHiddenDefectFields(index);
};
/**
* 소분류 선택 변경
*/
window.onDefectItemChange = async function(index, defectIndex, value) {
if (!tempDefects[index] || !tempDefects[index][defectIndex]) return;
const defect = tempDefects[index][defectIndex];
if (value === '__new__') {
// 새 아이템 추가 모달
const newName = prompt('새 소분류(항목) 이름을 입력하세요:');
if (newName && newName.trim() && defect.category_id) {
try {
const response = await window.apiCall('/work-issues/items', 'POST', {
category_id: defect.category_id,
item_name: newName.trim(),
severity: 'medium'
});
if (response.success) {
// 아이템 목록 새로고침
await loadErrorTypes();
// 새로 생성된 아이템 선택
const newItem = issueItems.find(i => i.item_name === newName.trim() && i.category_id == defect.category_id);
if (newItem) {
defect.item_id = newItem.item_id;
}
} else {
alert('항목 추가 실패: ' + (response.error || '알 수 없는 오류'));
}
} catch (e) {
alert('항목 추가 중 오류: ' + e.message);
}
}
} else if (value) {
defect.item_id = parseInt(value);
} else {
defect.item_id = null;
}
renderInlineDefectList(index);
updateHiddenDefectFields(index);
};
/**
* 이슈 부적합 토글 (체크박스)
*/
window.toggleIssueDefect = function(index, issueReportId, isChecked) {
if (!tempDefects[index]) {
tempDefects[index] = [];
}
if (isChecked) {
// 이슈 부적합 추가
tempDefects[index].push({
issue_report_id: issueReportId,
error_type_id: null, // 이슈 기반이므로 null
defect_hours: 0,
note: ''
});
} else {
// 이슈 부적합 제거
const idx = tempDefects[index].findIndex(d => d.issue_report_id == issueReportId);
if (idx !== -1) {
tempDefects[index].splice(idx, 1);
}
}
renderInlineDefectList(index);
updateHiddenDefectFields(index);
};
/**
* 이슈 부적합 시간 선택기 열기
*/
window.openIssueDefectTimePicker = function(index, issueReportId) {
// 해당 이슈의 defect 찾기
const defects = tempDefects[index] || [];
const defectIndex = defects.findIndex(d => d.issue_report_id == issueReportId);
if (defectIndex === -1) return;
currentEditingField = { index, type: 'issueDefect', issueReportId, defectIndex };
currentTimeValue = defects[defectIndex]?.defect_hours || 0;
// 팝오버 표시
const overlay = document.getElementById('timePickerOverlay');
const title = document.getElementById('timePickerTitle');
title.textContent = '부적합 시간 선택';
updateTimeDisplay();
overlay.style.display = 'flex';
// ESC 키로 닫기
document.addEventListener('keydown', handleEscapeKey);
};
/**
* 레거시 부적합 추가 (error_types 기반)
*/
window.addLegacyDefect = function(index) {
if (!tempDefects[index]) {
tempDefects[index] = [];
}
tempDefects[index].push({
issue_report_id: null,
category_id: null,
item_id: null,
error_type_id: '', // 레거시 호환
defect_hours: 0,
note: ''
});
renderInlineDefectList(index);
};
/**
* 부적합 카테고리/아이템 선택 업데이트 (레거시 호환)
* @deprecated onDefectCategoryChange, onDefectItemChange 사용 권장
*/
window.updateDefectCategory = function(index, defectIndex, value) {
// 레거시 호환 - 새 함수로 리다이렉트
if (value && value.startsWith('cat_')) {
onDefectCategoryChange(index, defectIndex, value.replace('cat_', ''));
} else if (value && value.startsWith('item_')) {
const itemId = parseInt(value.replace('item_', ''));
const item = issueItems.find(i => i.item_id === itemId);
if (item) {
if (!tempDefects[index]?.[defectIndex]) return;
tempDefects[index][defectIndex].category_id = item.category_id;
tempDefects[index][defectIndex].item_id = itemId;
renderInlineDefectList(index);
updateHiddenDefectFields(index);
}
}
};
/**
* 인라인 부적합 추가 (레거시 호환)
*/
window.addInlineDefect = function(index) {
addLegacyDefect(index);
};
/**
* 부적합 저장 확인 (유효성 검사 후 저장 상태로 변경)
*/
window.saveDefectsConfirm = function(index) {
const defects = tempDefects[index] || [];
// 입력 중인 항목만 (저장되지 않은 항목)
const editingDefects = defects.filter(d => !d.issue_report_id && !d._saved);
if (editingDefects.length === 0) {
alert('저장할 부적합 항목이 없습니다.\n"+ 부적합 추가" 버튼을 눌러 항목을 추가하세요.');
return;
}
// 유효성 검사
const invalidDefects = [];
editingDefects.forEach((defect, i) => {
const errors = [];
if (!defect.category_id) {
errors.push('대분류');
}
if (!defect.defect_hours || defect.defect_hours <= 0) {
errors.push('시간');
}
if (errors.length > 0) {
invalidDefects.push({ index: i + 1, errors });
}
});
if (invalidDefects.length > 0) {
const errorMsg = invalidDefects.map(d =>
`${d.index}번째 항목: ${d.errors.join(', ')} 미입력`
).join('\n');
alert(`입력이 완료되지 않은 항목이 있습니다.\n\n${errorMsg}`);
return;
}
// 모든 입력 중인 항목을 저장 상태로 변경
editingDefects.forEach(defect => {
defect._saved = true;
});
// UI 다시 렌더링
renderInlineDefectList(index);
updateHiddenDefectFields(index);
updateDefectSummary(index);
console.log(`[부적합 저장] index=${index}, 저장된 항목 수=${editingDefects.length}`);
};
/**
* 저장된 부적합 수정 (저장 상태 해제하여 입력 폼으로 이동)
*/
window.editSavedDefect = function(index, defectIndex) {
if (!tempDefects[index] || !tempDefects[index][defectIndex]) return;
// 원래 저장 상태였음을 기록 (취소 시 복원용)
tempDefects[index][defectIndex]._originalSaved = true;
tempDefects[index][defectIndex]._saved = false;
// UI 다시 렌더링
renderInlineDefectList(index);
};
/**
* 저장된 부적합 삭제
*/
window.deleteSavedDefect = function(index, defectIndex) {
if (!tempDefects[index] || !tempDefects[index][defectIndex]) return;
if (!confirm('이 부적합 항목을 삭제하시겠습니까?')) return;
tempDefects[index].splice(defectIndex, 1);
// UI 다시 렌더링
renderInlineDefectList(index);
updateHiddenDefectFields(index);
updateDefectSummary(index);
};
/**
* 부적합 입력 취소 (저장되지 않은 항목 삭제)
*/
window.cancelDefectEdit = function(index, defectIndex) {
if (!tempDefects[index] || !tempDefects[index][defectIndex]) return;
const defect = tempDefects[index][defectIndex];
// 원래 저장된 항목이었으면 저장 상태로 복원, 아니면 삭제
if (defect._originalSaved) {
defect._saved = true;
delete defect._originalSaved;
} else {
tempDefects[index].splice(defectIndex, 1);
}
// UI 다시 렌더링
renderInlineDefectList(index);
updateHiddenDefectFields(index);
updateDefectSummary(index);
};
/**
* 인라인 부적합 수정
*/
window.updateInlineDefect = function(index, defectIndex, field, value) {
if (tempDefects[index] && tempDefects[index][defectIndex]) {
if (field === 'defect_hours') {
tempDefects[index][defectIndex][field] = parseFloat(value) || 0;
} else {
tempDefects[index][defectIndex][field] = value;
}
updateDefectSummary(index);
updateHiddenDefectFields(index);
}
};
/**
* 인라인 부적합 삭제
*/
window.removeInlineDefect = function(index, defectIndex) {
if (tempDefects[index]) {
tempDefects[index].splice(defectIndex, 1);
// UI 다시 렌더링 (항상 - 빈 상태도 표시)
renderInlineDefectList(index);
updateDefectSummary(index);
updateHiddenDefectFields(index);
}
};
/**
* 부적합 시간 선택기 열기 (시간 선택 팝오버 재사용)
*/
window.openDefectTimePicker = function(index, defectIndex) {
currentEditingField = { index, type: 'defect', defectIndex };
// 현재 값 가져오기
const defects = tempDefects[index] || [];
currentTimeValue = defects[defectIndex]?.defect_hours || 0;
// 팝오버 표시
const overlay = document.getElementById('timePickerOverlay');
const title = document.getElementById('timePickerTitle');
title.textContent = '부적합 시간 선택';
updateTimeDisplay();
overlay.style.display = 'flex';
// ESC 키로 닫기
document.addEventListener('keydown', handleEscapeKey);
};
/**
* hidden input 필드 업데이트
*/
function updateHiddenDefectFields(index) {
const defects = tempDefects[index] || [];
// 총 부적합 시간 계산
const totalErrorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
// hidden input에 대표 error_type_id 저장 (첫 번째 값)
const errorTypeInput = document.getElementById(`errorType_${index}`);
if (errorTypeInput && defects.length > 0 && defects[0].error_type_id) {
errorTypeInput.value = defects[0].error_type_id;
} else if (errorTypeInput) {
errorTypeInput.value = '';
}
// 부적합 시간 input 업데이트
const errorHoursInput = document.getElementById(`errorHours_${index}`);
if (errorHoursInput) {
errorHoursInput.value = totalErrorHours;
}
}
/**
* 부적합 요약 텍스트 업데이트
* - 이슈 기반 부적합과 레거시 부적합 모두 포함
*/
function updateDefectSummary(index) {
const summaryEl = document.getElementById(`defectSummary_${index}`);
const toggleBtn = document.getElementById(`defectToggle_${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);
if (validDefects.length === 0) {
summaryEl.textContent = '없음';
summaryEl.style.color = '#6b7280';
if (toggleBtn) toggleBtn.classList.remove('has-defect');
} else {
const totalHours = validDefects.reduce((sum, d) => sum + d.defect_hours, 0);
if (validDefects.length === 1) {
let typeName = '부적합';
if (validDefects[0].issue_report_id) {
// 이슈 기반 - 이슈 캐시에서 이름 찾기
const tbm = incompleteTbms[index];
if (tbm) {
const dateStr = formatDateForApi(tbm.session_date);
const issues = dailyIssuesCache[dateStr] || [];
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].error_type_id) {
// 레거시 - error_types에서 이름 찾기
typeName = errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합';
}
summaryEl.textContent = `${typeName} ${totalHours}h`;
} else {
summaryEl.textContent = `${validDefects.length}${totalHours}h`;
}
summaryEl.style.color = '#dc2626';
if (toggleBtn) toggleBtn.classList.add('has-defect');
}
// hidden 필드도 업데이트
updateHiddenDefectFields(index);
}