Files
TK-FB-Project/fastapi-bridge/static/js/work-report-review.js

1055 lines
38 KiB
JavaScript

// js/work-report-review.js - 휴가 연동 완전판
import { API, getAuthHeaders } from '/js/api-config.js';
class WorkReportReviewManager {
constructor() {
this.workers = [];
this.projects = [];
this.workTypes = [];
this.workStatusTypes = [];
this.errorTypes = [];
this.reports = [];
this.filteredReports = [];
this.attendanceData = []; // 휴가 정보
this.reviewedReports = new Set(); // 검토완료된 보고서 ID
this.selectedReport = null;
this.init();
}
async init() {
try {
// 필수 DOM 요소 확인
const requiredElements = ['reportsTableBody', 'editPanelContent', 'totalReports'];
const missingElements = requiredElements.filter(id => !document.getElementById(id));
if (missingElements.length > 0) {
console.error('필수 DOM 요소가 없습니다:', missingElements);
return;
}
this.showLoading(true);
await this.loadData();
this.setupEventListeners();
this.setDefaultDates();
await this.loadReports();
this.showLoading(false);
} catch (error) {
this.showMessage('데이터 로드 중 오류가 발생했습니다.', 'error');
console.error('초기화 오류:', error);
this.showLoading(false);
}
}
async loadData() {
try {
// 기본 데이터 로딩
const [workersRes, projectsRes, workTypesRes, workStatusRes, errorTypesRes] = await Promise.all([
fetch(`${API}/workers`, { headers: getAuthHeaders() }),
fetch(`${API}/projects`, { headers: getAuthHeaders() }),
fetch(`${API}/daily-work-reports/work-types`, { headers: getAuthHeaders() }),
fetch(`${API}/daily-work-reports/work-status-types`, { headers: getAuthHeaders() }),
fetch(`${API}/daily-work-reports/error-types`, { headers: getAuthHeaders() })
]);
if (!workersRes.ok || !projectsRes.ok) {
throw new Error(`API 응답 오류: Workers(${workersRes.status}), Projects(${projectsRes.status})`);
}
this.workers = await workersRes.json();
this.projects = await projectsRes.json();
// 새로운 API들 로드 (실패해도 기본값 사용)
try {
this.workTypes = workTypesRes.ok ? await workTypesRes.json() : [
{id: 1, name: 'Base'}, {id: 2, name: 'Vessel'}, {id: 3, name: 'Piping'}
];
this.workStatusTypes = workStatusRes.ok ? await workStatusRes.json() : [
{id: 1, name: '정규'}, {id: 2, name: '에러'}
];
this.errorTypes = errorTypesRes.ok ? await errorTypesRes.json() : [
{id: 1, name: '설계미스'}, {id: 2, name: '외주작업 불량'},
{id: 3, name: '입고지연'}, {id: 4, name: '작업 불량'}
];
} catch (error) {
console.log('⚠️ 일부 API 사용 불가, 기본값 사용');
}
// 휴가 정보 로드
await this.loadAttendanceData();
this.populateFilters();
console.log('기본 데이터 로드 완료');
} catch (error) {
console.error('기본 데이터 로드 오류:', error);
throw error;
}
}
async loadAttendanceData() {
try {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
if (!startDate || !endDate) return;
// 휴가 정보 API 호출 (예상 엔드포인트)
const response = await fetch(`${API}/attendance?start_date=${startDate}&end_date=${endDate}`, {
headers: getAuthHeaders()
});
if (response.ok) {
this.attendanceData = await response.json();
console.log('휴가 정보 로드 완료:', this.attendanceData.length);
} else if (response.status === 404) {
console.log('⚠️ 휴가 API 없음, 더미 데이터 생성');
this.attendanceData = this.generateDummyAttendance();
} else {
throw new Error(`휴가 정보 로드 실패: ${response.status}`);
}
} catch (error) {
console.log('⚠️ 휴가 정보 로드 오류, 더미 데이터 사용:', error.message);
this.attendanceData = this.generateDummyAttendance();
}
}
generateDummyAttendance() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
if (!startDate || !endDate) return [];
const dummyAttendance = [];
const start = new Date(startDate);
const end = new Date(endDate);
// 날짜별로 작업자들의 출근 상태 생성
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
this.workers.forEach(worker => {
const rand = Math.random();
let attendanceType = 'NORMAL'; // 기본값
// 5% 확률로 휴가
if (rand < 0.05) {
const types = ['HALF_DAY', 'HALF_HALF_DAY', 'EARLY_LEAVE'];
attendanceType = types[Math.floor(Math.random() * types.length)];
}
dummyAttendance.push({
id: `att_${worker.worker_id}_${dateStr}`,
worker_id: worker.worker_id,
worker_name: worker.worker_name,
date: dateStr,
attendance_type: attendanceType,
expected_hours: this.getExpectedHours(attendanceType)
});
});
}
return dummyAttendance;
}
getExpectedHours(attendanceType) {
switch (attendanceType) {
case 'HALF_DAY': return 4; // 반차
case 'HALF_HALF_DAY': return 6; // 반반차
case 'EARLY_LEAVE': return 2; // 조퇴
case 'NORMAL':
default: return 8; // 일반근무
}
}
getAttendanceTypeName(attendanceType) {
switch (attendanceType) {
case 'HALF_DAY': return '반차';
case 'HALF_HALF_DAY': return '반반차';
case 'EARLY_LEAVE': return '조퇴';
case 'NORMAL':
default: return '정상근무';
}
}
async loadReports() {
try {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const workerId = document.getElementById('workerFilter').value;
const projectId = document.getElementById('projectFilter').value;
// 필수 조건 체크
if (!startDate || !endDate) {
this.showMessage('시작 날짜와 종료 날짜는 필수입니다.', 'error');
return;
}
// 휴가 정보 먼저 로드
await this.loadAttendanceData();
let url = `${API}/daily-work-reports/search?`;
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (workerId) params.append('worker_id', workerId);
if (projectId) params.append('project_id', projectId);
// 페이지네이션 (일단 많이 가져오기)
params.append('page', '1');
params.append('limit', '1000');
url += params.toString();
const response = await fetch(url, { headers: getAuthHeaders() });
if (response.status === 404 || response.status === 500) {
// API가 아직 준비되지 않은 경우 더미 데이터 사용
this.reports = this.generateDummyData();
console.log('⚠️ API 응답 오류, 더미 데이터 사용:', response.status);
if (response.status === 404) {
this.showMessage('⚠️ 검토 API가 준비되지 않아 더미 데이터를 표시합니다.', 'warning');
} else {
this.showMessage('⚠️ 서버 오류로 더미 데이터를 표시합니다.', 'warning');
}
} else if (response.ok) {
const result = await response.json();
this.reports = result.reports || [];
console.log('검색 결과:', result.summary);
} else {
throw new Error(`보고서 로드 실패: ${response.status}`);
}
// 근무시간 검증 추가
this.validateWorkHours();
this.filteredReports = [...this.reports];
this.updateDashboard();
this.updateAlertsSection();
this.updateTable();
} catch (error) {
console.log('⚠️ 네트워크 오류로 더미 데이터 사용:', error.message);
// 더미 데이터로 대체
this.reports = this.generateDummyData();
this.validateWorkHours();
this.filteredReports = [...this.reports];
this.updateDashboard();
this.updateAlertsSection();
this.updateTable();
// 사용자에게는 부드러운 메시지 표시
this.showMessage('💡 데모 데이터로 화면을 구성했습니다.', 'info');
}
}
validateWorkHours() {
// 각 보고서에 대해 근무시간 검증
this.reports.forEach(report => {
const attendance = this.attendanceData.find(att =>
att.worker_id === report.worker_id && att.date === report.report_date
);
if (attendance) {
const expectedHours = attendance.expected_hours;
const actualHours = this.getWorkerDailyHours(report.worker_id, report.report_date);
report.expected_hours = expectedHours;
report.actual_hours = actualHours;
report.attendance_type = attendance.attendance_type;
report.hours_status = this.getHoursStatus(actualHours, expectedHours);
report.is_reviewed = this.reviewedReports.has(`${report.worker_id}_${report.report_date}`);
} else {
// 휴가 정보가 없으면 기본 8시간으로 가정
const actualHours = this.getWorkerDailyHours(report.worker_id, report.report_date);
report.expected_hours = 8;
report.actual_hours = actualHours;
report.attendance_type = 'NORMAL';
report.hours_status = this.getHoursStatus(actualHours, 8);
report.is_reviewed = this.reviewedReports.has(`${report.worker_id}_${report.report_date}`);
}
});
}
getWorkerDailyHours(workerId, date) {
// 해당 작업자의 특정 날짜 총 작업시간 계산
return this.reports
.filter(r => r.worker_id === workerId && r.report_date === date)
.reduce((sum, r) => sum + (r.work_hours || 0), 0);
}
getHoursStatus(actualHours, expectedHours) {
const tolerance = 0.5; // 30분 허용 오차
if (Math.abs(actualHours - expectedHours) <= tolerance) {
return 'NORMAL'; // 정상
} else if (actualHours < expectedHours) {
return 'UNDER'; // 부족
} else {
return 'OVER'; // 초과
}
}
generateDummyData() {
const today = new Date();
const dummyData = [];
// 더미 데이터 생성 (최근 7일)
for (let i = 0; i < 7; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
this.workers.forEach(worker => {
// 80% 확률로 보고서 생성
if (Math.random() > 0.2) {
const numEntries = Math.floor(Math.random() * 3) + 1; // 1-3개 작업
for (let j = 0; j < numEntries; j++) {
const workStatus = Math.random() > 0.9 ? 2 : 1; // 10% 확률로 에러
const workHours = Math.random() > 0.95 ?
(Math.random() * 4 + 10) : // 5% 확률로 이상 시간 (10-14시간)
(Math.random() * 6 + 2); // 정상 시간 (2-8시간)
dummyData.push({
id: 1000000 + i * 100 + worker.worker_id * 10 + j, // 고유 정수 ID
report_date: dateStr,
worker_id: worker.worker_id,
worker_name: worker.worker_name,
project_id: this.projects[Math.floor(Math.random() * this.projects.length)]?.project_id || 1,
project_name: this.projects[Math.floor(Math.random() * this.projects.length)]?.project_name || 'Unknown',
work_type_id: Math.floor(Math.random() * 3) + 1,
work_type_name: this.workTypes[Math.floor(Math.random() * 3)]?.name || 'Base',
work_status_id: workStatus,
work_status_name: workStatus === 2 ? '에러' : '정규',
error_type_id: workStatus === 2 ? Math.floor(Math.random() * 4) + 1 : null,
error_type_name: workStatus === 2 ? this.errorTypes[Math.floor(Math.random() * 4)]?.name : null,
work_hours: Math.round(workHours * 2) / 2, // 0.5시간 단위
created_at: `${dateStr}T${String(Math.floor(Math.random() * 24)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}:00`
});
}
}
});
}
return dummyData.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}
populateFilters() {
// 작업자 필터
const workerSelect = document.getElementById('workerFilter');
if (workerSelect) {
workerSelect.innerHTML = '<option value="">전체 작업자</option>';
this.workers.forEach(worker => {
workerSelect.innerHTML += `<option value="${worker.worker_id}">${worker.worker_name}</option>`;
});
}
// 프로젝트 필터
const projectSelect = document.getElementById('projectFilter');
if (projectSelect) {
projectSelect.innerHTML = '<option value="">전체 프로젝트</option>';
this.projects.forEach(project => {
projectSelect.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
});
}
}
setDefaultDates() {
const today = new Date();
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
const startDateInput = document.getElementById('startDate');
const endDateInput = document.getElementById('endDate');
if (startDateInput) startDateInput.value = weekAgo.toISOString().split('T')[0];
if (endDateInput) endDateInput.value = today.toISOString().split('T')[0];
}
updateDashboard() {
// 날짜별, 작업자별로 그룹화하여 계산
const groupedReports = this.groupReportsByWorkerAndDate();
const groups = Object.values(groupedReports);
const total = groups.length;
const errors = groups.filter(group => group.some(r => r.work_status_id === 2)).length;
const warnings = groups.filter(group => {
const firstReport = group[0];
return firstReport.hours_status === 'UNDER' || firstReport.hours_status === 'OVER';
}).length;
const reviewed = groups.filter(group => group[0].is_reviewed).length;
document.getElementById('totalReports').textContent = total;
document.getElementById('errorReports').textContent = errors;
document.getElementById('warningReports').textContent = warnings;
document.getElementById('missingReports').textContent = total - reviewed; // 미검토 건수
}
updateAlertsSection() {
const alertsList = document.getElementById('alertsList');
if (!alertsList) return;
const alerts = [];
const groupedReports = this.groupReportsByWorkerAndDate();
// 각 그룹별로 알림 체크
Object.values(groupedReports).forEach(group => {
const firstReport = group[0];
const hasError = group.some(r => r.work_status_id === 2);
const workerName = firstReport.worker_name;
const date = firstReport.report_date;
// 에러 발생 건
if (hasError) {
const errorReports = group.filter(r => r.work_status_id === 2);
alerts.push({
type: 'error',
text: `${workerName} (${date}) - 에러 ${errorReports.length}`,
time: this.formatDate(date),
priority: 1
});
}
// 시간 불일치
if (firstReport.hours_status === 'UNDER' || firstReport.hours_status === 'OVER') {
const statusText = firstReport.hours_status === 'UNDER' ? '부족' : '초과';
alerts.push({
type: 'warning',
text: `${workerName} (${date}) - 시간 ${statusText} (${firstReport.actual_hours}h/${firstReport.expected_hours}h)`,
time: this.formatDate(date),
priority: 2
});
}
// 검토 대기 (정상이지만 아직 검토되지 않음)
if (!firstReport.is_reviewed && firstReport.hours_status === 'NORMAL' && !hasError) {
alerts.push({
type: 'pending',
text: `${workerName} (${date}) - 검토 대기`,
time: this.formatDate(date),
priority: 3
});
}
});
// 우선순위별 정렬
alerts.sort((a, b) => a.priority - b.priority);
// 알림 표시
if (alerts.length === 0) {
alertsList.innerHTML = `
<div class="alert-item">
<div style="text-align: center; color: #28a745; padding: 20px;">
✅ 모든 항목이 정상이거나 검토 완료되었습니다
</div>
</div>
`;
} else {
alertsList.innerHTML = alerts.map(alert => `
<div class="alert-item">
<div class="alert-content">
<div class="alert-text">
<span class="alert-type ${alert.type}">${this.getAlertTypeLabel(alert.type)}</span>
${alert.text}
</div>
<div class="alert-time">${alert.time}</div>
</div>
</div>
`).join('');
}
}
getAlertTypeLabel(type) {
switch (type) {
case 'error': return '에러';
case 'warning': return '주의';
case 'pending': return '대기';
case 'missing': return '미입력';
default: return '알림';
}
}
updateTable() {
const tbody = document.getElementById('reportsTableBody');
if (!tbody) return;
if (this.filteredReports.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="11" style="text-align: center; padding: 40px; color: #999;">
조회된 데이터가 없습니다
</td>
</tr>
`;
return;
}
// 날짜별, 작업자별로 그룹화
const groupedReports = this.groupReportsByWorkerAndDate();
tbody.innerHTML = Object.entries(groupedReports).map(([key, group]) => {
const [workerId, date] = key.split('_');
const firstReport = group[0];
const totalHours = group.reduce((sum, r) => sum + (r.work_hours || 0), 0);
const hasError = group.some(r => r.work_status_id === 2);
const rowClass = this.getGroupRowClass(firstReport, hasError);
return `
<tr class="${rowClass}" onclick="workReportReview.selectWorkerDate('${workerId}', '${date}')">
<td>${this.formatDate(firstReport.report_date)}</td>
<td>${firstReport.worker_name}</td>
<td>
<span class="attendance-badge ${firstReport.attendance_type}">
${this.getAttendanceTypeName(firstReport.attendance_type)}
</span>
</td>
<td>${firstReport.expected_hours}h</td>
<td>${totalHours}h</td>
<td>
<span class="hours-status-badge ${firstReport.hours_status}">
${this.getHoursStatusName(firstReport.hours_status)}
</span>
</td>
<td>${group.map(r => r.project_name).join(', ')}</td>
<td>${group.map(r => r.work_type_name).join(', ')}</td>
<td>
${hasError ? '<span class="status-badge error">에러 포함</span>' : '<span class="status-badge normal">정상</span>'}
</td>
<td>
${firstReport.is_reviewed ?
'<span class="review-badge reviewed">검토완료</span>' :
'<span class="review-badge pending">검토대기</span>'
}
</td>
<td onclick="event.stopPropagation();">
${!firstReport.is_reviewed && firstReport.hours_status === 'NORMAL' && !hasError ?
`<button class="review-complete-btn" onclick="workReportReview.markAsReviewed('${workerId}', '${date}')">✅ 검토완료</button>` :
'<span style="color: #999;">-</span>'
}
</td>
</tr>
`;
}).join('');
}
groupReportsByWorkerAndDate() {
const grouped = {};
this.filteredReports.forEach(report => {
const key = `${report.worker_id}_${report.report_date}`;
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(report);
});
return grouped;
}
getGroupRowClass(report, hasError) {
if (report.is_reviewed) return 'row-reviewed';
if (hasError || report.work_status_id === 2) return 'row-error';
if (report.hours_status === 'UNDER' || report.hours_status === 'OVER') return 'row-warning';
return 'row-normal';
}
getHoursStatusName(status) {
switch (status) {
case 'NORMAL': return '정상';
case 'UNDER': return '부족';
case 'OVER': return '초과';
default: return '확인필요';
}
}
selectWorkerDate(workerId, date) {
// 해당 작업자의 특정 날짜 보고서들을 선택
const reports = this.filteredReports.filter(r =>
r.worker_id == workerId && r.report_date === date
);
if (reports.length > 0) {
this.selectedReport = reports[0]; // 첫 번째 보고서를 대표로 선택
this.selectedReport.allReports = reports; // 전체 보고서 목록 추가
this.updateEditPanel();
// 테이블에서 선택 표시
document.querySelectorAll('.data-table tr.selected').forEach(tr => {
tr.classList.remove('selected');
});
// 해당 행 선택 표시
const rows = document.querySelectorAll('.data-table tr');
rows.forEach(row => {
if (row.onclick && row.onclick.toString().includes(`'${workerId}', '${date}'`)) {
row.classList.add('selected');
}
});
}
}
async markAsReviewed(workerId, date) {
try {
const key = `${workerId}_${date}`;
// API 호출 (준비되면)
// const response = await fetch(`${API}/daily-work-reports/review`, {
// method: 'POST',
// headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
// body: JSON.stringify({ worker_id: workerId, report_date: date, reviewed: true })
// });
// 현재는 로컬 상태만 업데이트
this.reviewedReports.add(key);
// 해당 보고서들의 검토 상태 업데이트
this.reports.forEach(report => {
if (report.worker_id == workerId && report.report_date === date) {
report.is_reviewed = true;
}
});
this.filteredReports = [...this.reports];
this.updateDashboard();
this.updateAlertsSection();
this.updateTable();
this.showMessage(`${this.getWorkerName(workerId)}${date} 보고서가 검토완료 처리되었습니다.`, 'success');
} catch (error) {
this.showMessage('❌ 검토완료 처리 중 오류가 발생했습니다.', 'error');
console.error('검토완료 오류:', error);
}
}
getWorkerName(workerId) {
const worker = this.workers.find(w => w.worker_id == workerId);
return worker ? worker.worker_name : `작업자${workerId}`;
}
updateEditPanel() {
const panelContent = document.getElementById('editPanelContent');
if (!panelContent || !this.selectedReport) return;
const report = this.selectedReport;
const allReports = report.allReports || [report];
panelContent.innerHTML = `
<div style="margin-bottom: 20px; padding: 16px; background: #f8f9fa; border-radius: 8px;">
<h4 style="margin: 0 0 12px 0;">${report.worker_name} - ${this.formatDate(report.report_date)}</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; font-size: 14px;">
<div>
<strong>출근형태:</strong><br>
<span class="attendance-badge ${report.attendance_type}">
${this.getAttendanceTypeName(report.attendance_type)}
</span>
</div>
<div>
<strong>시간상태:</strong><br>
<span class="hours-status-badge ${report.hours_status}">
${this.getHoursStatusName(report.hours_status)}
</span>
</div>
<div><strong>기대시간:</strong> ${report.expected_hours}시간</div>
<div><strong>실제시간:</strong> ${report.actual_hours}시간</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<h5>작업 상세 (${allReports.length}건)</h5>
${allReports.map((r, index) => `
<div style="border: 1px solid #e1e5e9; border-radius: 8px; padding: 12px; margin-bottom: 8px; ${r.work_status_id === 2 ? 'background: #fef5f5;' : ''}">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 13px;">
<div><strong>프로젝트:</strong> ${r.project_name}</div>
<div><strong>작업유형:</strong> ${r.work_type_name}</div>
<div><strong>작업시간:</strong> ${r.work_hours}시간</div>
<div><strong>상태:</strong> ${r.work_status_name}</div>
${r.error_type_name ? `<div style="grid-column: span 2;"><strong>에러유형:</strong> ${r.error_type_name}</div>` : ''}
</div>
<button onclick="workReportReview.editSingleReport('${r.id}')"
style="margin-top: 8px; padding: 4px 8px; border: 1px solid #007bff; background: none; color: #007bff; border-radius: 4px; cursor: pointer; font-size: 12px;">
수정
</button>
</div>
`).join('')}
</div>
<div class="panel-actions">
${!report.is_reviewed && report.hours_status === 'NORMAL' && !allReports.some(r => r.work_status_id === 2) ?
`<button type="button" class="panel-btn save" onclick="workReportReview.markAsReviewed('${report.worker_id}', '${report.report_date}')">
✅ 검토완료 처리
</button>` : ''
}
<button type="button" class="panel-btn cancel" onclick="workReportReview.clearSelection()">
✖️ 닫기
</button>
</div>
`;
}
editSingleReport(reportId) {
// 개별 보고서 수정을 위해 기존 로직 사용
this.selectedReport = this.filteredReports.find(r => r.id == reportId);
if (this.selectedReport) {
// 기존 수정 폼 표시
this.showEditForm();
}
}
showEditForm() {
const panelContent = document.getElementById('editPanelContent');
if (!panelContent || !this.selectedReport) return;
const report = this.selectedReport;
panelContent.innerHTML = `
<form id="editForm">
<div class="form-group">
<label>작업자</label>
<input type="text" value="${report.worker_name}" disabled class="form-input">
</div>
<div class="form-group">
<label>작업 날짜</label>
<input type="date" id="editReportDate" value="${report.report_date}" class="form-input">
</div>
<div class="form-group">
<label>프로젝트</label>
<select id="editProject" class="form-input">
${this.projects.map(p => `
<option value="${p.project_id}" ${p.project_id == report.project_id ? 'selected' : ''}>
${p.project_name}
</option>
`).join('')}
</select>
</div>
<div class="form-group">
<label>작업 유형</label>
<select id="editWorkType" class="form-input">
${this.workTypes.map(wt => `
<option value="${wt.id}" ${wt.id == report.work_type_id ? 'selected' : ''}>
${wt.name}
</option>
`).join('')}
</select>
</div>
<div class="form-group">
<label>업무 상태</label>
<select id="editWorkStatus" class="form-input">
${this.workStatusTypes.map(ws => `
<option value="${ws.id}" ${ws.id == report.work_status_id ? 'selected' : ''}>
${ws.name}
</option>
`).join('')}
</select>
</div>
<div class="form-group" id="editErrorTypeGroup" style="${report.work_status_id === 2 ? '' : 'display: none;'}">
<label>에러 유형</label>
<select id="editErrorType" class="form-input">
<option value="">선택하세요</option>
${this.errorTypes.map(et => `
<option value="${et.id}" ${et.id == report.error_type_id ? 'selected' : ''}>
${et.name}
</option>
`).join('')}
</select>
</div>
<div class="form-group">
<label>작업 시간</label>
<input type="number" id="editWorkHours" value="${report.work_hours}"
min="0" max="24" step="0.5" class="form-input">
</div>
</form>
<div class="panel-actions">
<button type="button" class="panel-btn save" onclick="workReportReview.saveReport()">
💾 저장
</button>
<button type="button" class="panel-btn delete" onclick="workReportReview.deleteReport()">
🗑️ 삭제
</button>
<button type="button" class="panel-btn cancel" onclick="workReportReview.clearSelection()">
✖️ 취소
</button>
</div>
`;
// 업무 상태 변경 시 에러 유형 표시/숨김
const workStatusSelect = document.getElementById('editWorkStatus');
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
if (workStatusSelect && errorTypeGroup) {
workStatusSelect.addEventListener('change', (e) => {
if (e.target.value === '2') {
errorTypeGroup.style.display = 'block';
} else {
errorTypeGroup.style.display = 'none';
document.getElementById('editErrorType').value = '';
}
});
}
}
async saveReport() {
if (!this.selectedReport) return;
try {
this.showLoading(true);
const formData = {
report_date: document.getElementById('editReportDate').value,
project_id: parseInt(document.getElementById('editProject').value),
work_type_id: parseInt(document.getElementById('editWorkType').value),
work_status_id: parseInt(document.getElementById('editWorkStatus').value),
error_type_id: document.getElementById('editErrorType').value ?
parseInt(document.getElementById('editErrorType').value) : null,
work_hours: parseFloat(document.getElementById('editWorkHours').value)
};
const response = await fetch(`${API}/daily-work-reports/${this.selectedReport.id}`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.status === 404 || response.status === 500) {
if (response.status === 404) {
this.showMessage('⚠️ 수정 API가 아직 준비되지 않았습니다.', 'warning');
} else {
this.showMessage('⚠️ 서버 오류가 발생했습니다. 더미 데이터를 수정합니다.', 'warning');
}
// 더미 데이터 업데이트
this.updateDummyData(formData);
} else if (response.ok) {
this.showMessage('✅ 성공적으로 수정되었습니다.', 'success');
await this.loadReports();
} else {
const result = await response.json();
throw new Error(result.error || '수정 중 오류가 발생했습니다.');
}
this.clearSelection();
} catch (error) {
this.showMessage(`${error.message}`, 'error');
console.error('수정 오류:', error);
} finally {
this.showLoading(false);
}
}
updateDummyData(formData) {
// 더미 데이터에서 수정
const reportIndex = this.reports.findIndex(r => r.id === this.selectedReport.id);
if (reportIndex !== -1) {
this.reports[reportIndex] = {
...this.reports[reportIndex],
...formData,
project_name: this.projects.find(p => p.project_id === formData.project_id)?.project_name,
work_type_name: this.workTypes.find(wt => wt.id === formData.work_type_id)?.name,
work_status_name: this.workStatusTypes.find(ws => ws.id === formData.work_status_id)?.name,
error_type_name: formData.error_type_id ?
this.errorTypes.find(et => et.id === formData.error_type_id)?.name : null
};
// 시간 검증 다시 실행
this.validateWorkHours();
this.filteredReports = [...this.reports];
this.updateDashboard();
this.updateAlertsSection();
this.updateTable();
}
}
async deleteReport() {
if (!this.selectedReport) return;
if (!confirm('정말로 이 보고서를 삭제하시겠습니까?')) return;
try {
this.showLoading(true);
const response = await fetch(`${API}/daily-work-reports/${this.selectedReport.id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (response.status === 404 || response.status === 500) {
if (response.status === 404) {
this.showMessage('⚠️ 삭제 API가 아직 준비되지 않았습니다.', 'warning');
} else {
this.showMessage('⚠️ 서버 오류가 발생했습니다. 더미 데이터를 삭제합니다.', 'warning');
}
// 더미 데이터에서 삭제
this.reports = this.reports.filter(r => r.id !== this.selectedReport.id);
this.filteredReports = [...this.reports];
this.updateDashboard();
this.updateAlertsSection();
this.updateTable();
} else if (response.ok) {
this.showMessage('✅ 성공적으로 삭제되었습니다.', 'success');
await this.loadReports();
} else {
const result = await response.json();
throw new Error(result.error || '삭제 중 오류가 발생했습니다.');
}
this.clearSelection();
} catch (error) {
this.showMessage(`${error.message}`, 'error');
console.error('삭제 오류:', error);
} finally {
this.showLoading(false);
}
}
clearSelection() {
this.selectedReport = null;
// 테이블 선택 해제
document.querySelectorAll('.data-table tr.selected').forEach(tr => {
tr.classList.remove('selected');
});
// 패널 초기화
const panelContent = document.getElementById('editPanelContent');
if (panelContent) {
panelContent.innerHTML = `
<div class="panel-empty">
<div class="panel-empty-icon">📝</div>
<div>수정할 항목을 선택해주세요</div>
</div>
`;
}
}
setupEventListeners() {
// 필터 적용 버튼
const applyFilterBtn = document.getElementById('applyFilter');
if (applyFilterBtn) {
applyFilterBtn.addEventListener('click', () => {
this.loadReports();
});
}
// 새로고침 버튼
const refreshBtn = document.getElementById('refreshBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
this.loadReports();
});
}
// 내보내기 버튼
const exportBtn = document.getElementById('exportBtn');
if (exportBtn) {
exportBtn.addEventListener('click', () => {
this.exportData();
});
}
// 엔터키로 필터 적용
['startDate', 'endDate', 'workerFilter', 'projectFilter'].forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('change', () => {
this.loadReports();
});
}
});
}
exportData() {
if (this.filteredReports.length === 0) {
this.showMessage('내보낼 데이터가 없습니다.', 'warning');
return;
}
// CSV 생성
const headers = ['날짜', '작업자', '출근형태', '기대시간', '실제시간', '시간상태', '프로젝트', '작업유형', '상태', '검토상태'];
// 그룹화된 데이터로 CSV 생성
const groupedReports = this.groupReportsByWorkerAndDate();
const csvContent = [
headers.join(','),
...Object.entries(groupedReports).map(([key, group]) => {
const firstReport = group[0];
const totalHours = group.reduce((sum, r) => sum + (r.work_hours || 0), 0);
const hasError = group.some(r => r.work_status_id === 2);
return [
firstReport.report_date,
firstReport.worker_name,
this.getAttendanceTypeName(firstReport.attendance_type),
firstReport.expected_hours,
totalHours,
this.getHoursStatusName(firstReport.hours_status),
group.map(r => r.project_name).join('; '),
group.map(r => r.work_type_name).join('; '),
hasError ? '에러 포함' : '정상',
firstReport.is_reviewed ? '검토완료' : '검토대기'
].join(',');
})
].join('\n');
// 파일 다운로드
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `작업보고서_검토_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
this.showMessage('✅ 데이터가 내보내졌습니다.', 'success');
}
formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('ko-KR');
}
formatTime(dateTimeStr) {
return new Date(dateTimeStr).toLocaleString('ko-KR', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
showLoading(show) {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.style.display = show ? 'flex' : 'none';
}
}
showMessage(message, type) {
const container = document.getElementById('message-container');
if (container) {
container.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type === 'success') {
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
}
}
}
// 전역 인스턴스 생성
let workReportReview;
// DOM 준비 후 초기화
document.addEventListener('DOMContentLoaded', () => {
workReportReview = new WorkReportReviewManager();
// 전역 접근을 위해 window에 할당
window.workReportReview = workReportReview;
});