Files
TK-FB-Project/fastapi-bridge/static/js/attendance-validation.js

1038 lines
36 KiB
JavaScript

// 근태 검증 관리 시스템 - API 통합 개선판
// ========================================
// API 설정 및 인증 (통합 방식)
// ========================================
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
// ========================================
// 전역 변수 및 설정
// ========================================
let currentDate = new Date();
let selectedDate = null;
let selectedDateWorkers = [];
let currentFilter = 'all';
let editingWorker = null;
// Rate Limiting 설정 - 더욱 엄격하게
const RATE_LIMIT = {
maxConcurrent: 1, // 동시 최대 1개 요청만!
delayBetweenRequests: 2000, // 요청 간 2초 딜레이
retryDelay: 5000, // 429 에러 시 5초 후 재시도
maxRetries: 1 // 최대 1번만 재시도
};
// 캐시 및 상태 관리
let dateStatusCache = new Map();
let requestQueue = [];
let activeRequests = 0;
let isProcessingQueue = false;
// ========================================
// 캐시 및 성능 관리 유틸리티
// ========================================
/**
* 캐시 상태 확인
*/
function getCacheStatus() {
return {
cachedDates: dateStatusCache.size,
activeRequests: activeRequests,
queuedRequests: requestQueue.length,
isProcessingQueue: isProcessingQueue
};
}
/**
* 캐시 클리어
*/
function clearCache() {
dateStatusCache.clear();
console.log('📦 캐시가 클리어되었습니다.');
}
/**
* 성능 상태 UI 업데이트
*/
function updatePerformanceUI() {
const status = getCacheStatus();
const performanceEl = document.getElementById('performanceStatus');
if (performanceEl) {
document.getElementById('activeReq').textContent = status.activeRequests;
document.getElementById('cacheCount').textContent = status.cachedDates;
document.getElementById('queueCount').textContent = status.queuedRequests;
// 개발 환경에서만 표시
if (window.location.hostname === 'localhost' || window.location.hostname.includes('dev')) {
performanceEl.classList.remove('hidden');
}
}
}
/**
* 성능 모니터링 (디버그용)
*/
function logPerformanceStatus() {
const status = getCacheStatus();
console.log('📊 성능 상태:', status);
updatePerformanceUI();
}
// 개발 모드에서 성능 모니터링 (2초마다)
if (window.location.hostname === 'localhost' || window.location.hostname.includes('dev')) {
setInterval(logPerformanceStatus, 2000);
}
// ========================================
// 유틸리티 함수들
// ========================================
/**
* 한국 시간 기준 날짜 문자열 반환
*/
function getKoreaDateString(date = new Date()) {
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 getCurrentUser() {
try {
const token = localStorage.getItem('token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
return payload;
}
} catch (error) {
console.log('토큰에서 사용자 정보 추출 실패:', error);
}
try {
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo');
if (userInfo) {
return JSON.parse(userInfo);
}
} catch (error) {
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
}
return null;
}
// ========================================
// Rate Limiting 및 Queue 시스템
// ========================================
/**
* Rate Limited API 호출을 위한 Queue 처리
*/
async function processRequestQueue() {
if (isProcessingQueue || requestQueue.length === 0) return;
isProcessingQueue = true;
while (requestQueue.length > 0 && activeRequests < RATE_LIMIT.maxConcurrent) {
const { resolve, reject, url, options, retryCount } = requestQueue.shift();
activeRequests++;
try {
const result = await makeRateLimitedRequest(url, options, retryCount);
resolve(result);
} catch (error) {
reject(error);
} finally {
activeRequests--;
updatePerformanceUI(); // UI 업데이트
// 요청 간 딜레이
if (requestQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, RATE_LIMIT.delayBetweenRequests));
}
}
}
isProcessingQueue = false;
// 큐에 남은 요청이 있으면 다시 처리
if (requestQueue.length > 0) {
setTimeout(processRequestQueue, RATE_LIMIT.delayBetweenRequests);
}
}
/**
* Rate Limiting이 적용된 실제 API 호출
*/
async function makeRateLimitedRequest(url, options = {}, retryCount = 0) {
const defaultOptions = {
headers: getAuthHeaders()
};
const finalOptions = {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers
}
};
try {
const response = await fetch(url, finalOptions);
if (response.status === 401) {
showMessage('인증이 만료되었습니다. 다시 로그인해주세요.', 'error');
localStorage.removeItem('token');
setTimeout(() => {
window.location.href = '/';
}, 2000);
return;
}
if (response.status === 429) {
// Rate Limit 에러 처리
if (retryCount < RATE_LIMIT.maxRetries) {
console.log(`Rate limit 도달, ${RATE_LIMIT.retryDelay}ms 후 재시도 (${retryCount + 1}/${RATE_LIMIT.maxRetries})`);
await new Promise(resolve => setTimeout(resolve, RATE_LIMIT.retryDelay * (retryCount + 1)));
return makeRateLimitedRequest(url, options, retryCount + 1);
} else {
throw new Error('Rate limit exceeded - 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.');
}
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || errorData.message || `HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API 호출 오류:', error);
throw error;
}
}
/**
* API 호출 헬퍼 (Queue 시스템 사용) - 통합 apiCall 대신 사용
*/
async function queuedApiCall(url, options = {}) {
return new Promise((resolve, reject) => {
requestQueue.push({
resolve,
reject,
url,
options,
retryCount: 0
});
updatePerformanceUI(); // UI 업데이트
processRequestQueue();
});
}
// ========================================
// 메시지 및 UI 헬퍼 함수들
// ========================================
/**
* 메시지 표시
*/
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 showLoadingState() {
const workersList = document.getElementById('workersList');
workersList.innerHTML = `
<div class="loading-card">
<div class="flex items-center justify-center">
<div class="loading-spinner"></div>
<span class="ml-3 text-gray-600">작업자 데이터를 불러오는 중...</span>
</div>
</div>
`;
}
/**
* 오류 상태 표시
*/
function showErrorState(message = '데이터를 불러오는 중 오류가 발생했습니다.') {
const workersList = document.getElementById('workersList');
workersList.innerHTML = `
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<h3 class="empty-title">데이터 로딩 오류</h3>
<p class="empty-description">${message}</p>
</div>
`;
}
// ========================================
// API 호출 함수들 (통합 설정 사용)
// ========================================
/**
* 특정 날짜의 작업 보고서를 가져옵니다
*/
async function fetchWorkReports(date) {
try {
return await queuedApiCall(`${API}/workreports/date/${date}`);
} catch (error) {
console.error('WorkReports API 호출 실패:', error);
return [];
}
}
/**
* 특정 날짜의 일일 작업 보고서를 가져옵니다
*/
async function fetchDailyWorkReports(date) {
try {
return await queuedApiCall(`${API}/daily-work-reports/date/${date}`);
} catch (error) {
console.error('DailyWorkReports API 호출 실패:', error);
return [];
}
}
/**
* 특정 작업자의 근무시간을 수정합니다
*/
async function updateWorkerHours(workerId, date, newHours, reason = '') {
try {
const data = {
worker_id: workerId,
report_date: date,
work_hours: parseFloat(newHours),
modification_reason: reason,
modified_by: getCurrentUser()?.user_id || getCurrentUser()?.id
};
return await queuedApiCall(`${API}/daily-work-reports/update-hours`, {
method: 'PUT',
body: JSON.stringify(data)
});
} catch (error) {
console.error('작업자 시간 수정 실패:', error);
throw error;
}
}
/**
* 특정 작업자의 작업 데이터를 삭제합니다
*/
async function deleteWorkerReport(workerId, date) {
try {
return await queuedApiCall(`${API}/daily-work-reports/worker/${workerId}/date/${date}`, {
method: 'DELETE'
});
} catch (error) {
console.error('작업자 데이터 삭제 실패:', error);
throw error;
}
}
// ========================================
// 계산 및 검증 함수들
// ========================================
/**
* 상태에 따른 예상 근무시간을 계산합니다
*/
function calculateExpectedHours(status, overtime_hours = 0) {
const baseHours = {
'normal': 8, // 정상 출근
'half_day': 4, // 반차
'early_leave': 4, // 조퇴
'quarter_day': 2, // 1/4 휴가
'vacation': 0, // 휴가
'sick_leave': 0 // 병가
};
return (baseHours[status] || 8) + (overtime_hours || 0);
}
/**
* 작업자의 검증 상태를 계산합니다
*/
function getValidationStatus(worker) {
if (!worker.hasWorkReport || !worker.hasDailyReport) return 'missing';
if (Math.abs(worker.difference) > 0) return 'needs-review';
return 'normal';
}
/**
* 특정 날짜의 전체 상태를 계산합니다 (순차 호출)
*/
async function calculateDateStatus(dateStr) {
// 캐시 확인
if (dateStatusCache.has(dateStr)) {
return dateStatusCache.get(dateStr);
}
try {
console.log(`📊 ${dateStr} 상태 계산 시작 - 순차 호출`);
// 1단계: WorkReports 먼저 가져오기
console.log(`📝 1단계: WorkReports 조회 중...`);
const workReports = await fetchWorkReports(dateStr);
// 2초 대기 (서버 부하 방지)
console.log(`⏳ 2초 대기 중... (서버 부하 방지)`);
await new Promise(resolve => setTimeout(resolve, 2000));
// 2단계: DailyWorkReports 가져오기
console.log(`📊 2단계: DailyWorkReports 조회 중...`);
const dailyReports = await fetchDailyWorkReports(dateStr);
let status;
if (workReports.length === 0 && dailyReports.length === 0) {
status = 'no-data';
} else if (workReports.length === 0 || dailyReports.length === 0) {
status = 'missing';
} else {
const hasDiscrepancy = workReports.some(wr => {
const dr = dailyReports.find(d => d.worker_id === wr.worker_id);
if (!dr) return true;
const expected = calculateExpectedHours(wr.status, wr.overtime_hours);
return Math.abs(dr.work_hours - expected) > 0;
});
status = hasDiscrepancy ? 'needs-review' : 'normal';
}
// 캐시에 저장
dateStatusCache.set(dateStr, status);
console.log(`${dateStr} 상태 계산 완료: ${status}`);
return status;
} catch (error) {
console.error('날짜 상태 계산 오류:', error);
// 에러 시 캐시하지 않고 기본값 반환
return 'no-data';
}
}
// ========================================
// 캘린더 관련 함수들
// ========================================
/**
* 현재 월의 캘린더 데이터를 생성합니다 (온디맨드 로딩)
*/
function generateCalendarStructure() {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
const calendar = [];
const current = new Date(startDate);
for (let week = 0; week < 6; week++) {
const weekDays = [];
for (let day = 0; day < 7; day++) {
const dateStr = getKoreaDateString(current);
const isCurrentMonth = current.getMonth() === month;
weekDays.push({
date: new Date(current),
dateStr,
isCurrentMonth,
status: 'no-data' // 모든 날짜를 no-data로 시작
});
current.setDate(current.getDate() + 1);
}
calendar.push(weekDays);
}
return calendar;
}
/**
* 캘린더를 화면에 렌더링합니다 (자동 로딩 없음)
*/
async function renderCalendar() {
try {
showMessage('📅 캘린더를 표시합니다. 날짜를 클릭하면 순차적으로 상태를 확인할 수 있습니다.', 'success');
// 캘린더 구조만 렌더링 (API 호출 없음)
const calendar = generateCalendarStructure();
const calendarGrid = document.getElementById('calendarGrid');
calendarGrid.innerHTML = '';
// 캘린더 날짜들을 생성 (상태 로딩 없음)
calendar.flat().forEach((dateInfo) => {
const button = document.createElement('button');
button.className = `
calendar-day
${dateInfo.isCurrentMonth ? 'text-gray-900 hover-enabled' : 'text-gray-400'}
${selectedDate === dateInfo.dateStr ? 'selected' : ''}
no-data
`;
button.innerHTML = `
<div class="relative">
<span class="text-lg font-semibold">${dateInfo.date.getDate()}</span>
${dateInfo.isCurrentMonth ?
`<div class="status-dot" style="background: #e5e7eb; opacity: 0.5;"></div>` : ''
}
</div>
`;
// 현재 월의 날짜만 클릭 가능
if (dateInfo.isCurrentMonth) {
button.addEventListener('click', () => handleDateClick(dateInfo));
button.title = `${dateInfo.dateStr} - 클릭하여 상태 확인`;
} else {
button.disabled = true;
button.style.cursor = 'not-allowed';
}
calendarGrid.appendChild(button);
});
// 월/년 표시 업데이트
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
document.getElementById('currentMonthYear').textContent =
`${currentDate.getFullYear()}${monthNames[currentDate.getMonth()]}`;
// 월간 요약 정보 초기화
document.getElementById('normalCount').textContent = '?';
document.getElementById('reviewCount').textContent = '?';
document.getElementById('missingCount').textContent = '?';
hideMessage();
} catch (error) {
console.error('캘린더 렌더링 오류:', error);
showMessage('캘린더 로딩 중 오류가 발생했습니다.', 'error');
}
}
/**
* 특정 날짜의 상태만 로드하고 업데이트합니다 (순차 호출)
*/
async function loadAndUpdateDateStatus(dateStr, buttonElement) {
try {
// 로딩 상태 표시
buttonElement.classList.add('loading-state');
const statusDot = buttonElement.querySelector('.status-dot');
if (statusDot) {
statusDot.style.background = '#3b82f6';
statusDot.style.opacity = '1';
statusDot.classList.add('pulse');
}
// 진행 상황 표시
buttonElement.title = '1단계: WorkReports 조회 중...';
const status = await calculateDateStatus(dateStr);
// 버튼 스타일 업데이트
buttonElement.classList.remove('loading-state', 'no-data');
buttonElement.classList.add(status);
buttonElement.title = `${dateStr} - 상태: ${status}`;
// 상태 점 업데이트
if (statusDot && status !== 'no-data') {
statusDot.classList.remove('pulse');
statusDot.className = `status-dot ${
status === 'needs-review' ? 'warning' :
status === 'missing' ? 'error' :
status === 'normal' ? 'normal' : ''
}`;
}
console.log(`${dateStr} 상태 로드 완료: ${status}`);
} catch (error) {
console.error(`${dateStr} 상태 로드 실패:`, error);
buttonElement.classList.remove('loading-state');
buttonElement.classList.add('error-state');
buttonElement.title = `${dateStr} - 로드 실패: ${error.message}`;
const statusDot = buttonElement.querySelector('.status-dot');
if (statusDot) {
statusDot.style.background = '#ef4444';
statusDot.classList.remove('pulse');
}
}
}
// ========================================
// 작업자 데이터 관련 함수들
// ========================================
/**
* 특정 날짜의 모든 작업자 데이터를 조합합니다 (순차 호출)
*/
async function getWorkersForDate(dateStr) {
try {
console.log(`👥 ${dateStr} 작업자 데이터 조합 시작 - 순차 호출`);
// 1단계: WorkReports 먼저 가져오기
console.log(`📝 1단계: WorkReports 조회 중...`);
const workReports = await fetchWorkReports(dateStr);
// 2초 대기 (서버 부하 방지)
console.log(`⏳ 2초 대기 중... (서버 부하 방지)`);
await new Promise(resolve => setTimeout(resolve, 2000));
// 2단계: DailyWorkReports 가져오기
console.log(`📊 2단계: DailyWorkReports 조회 중...`);
const dailyReports = await fetchDailyWorkReports(dateStr);
const workerMap = new Map();
// WorkReports 데이터 추가
workReports.forEach(wr => {
workerMap.set(wr.worker_id, {
worker_id: wr.worker_id,
worker_name: wr.worker_name,
overtime_hours: wr.overtime_hours || 0,
status: wr.status || 'normal',
expected_hours: calculateExpectedHours(wr.status || 'normal', wr.overtime_hours),
reported_hours: null,
hasWorkReport: true,
hasDailyReport: false
});
});
// DailyReports 데이터 추가
dailyReports.forEach(dr => {
if (workerMap.has(dr.worker_id)) {
const worker = workerMap.get(dr.worker_id);
worker.reported_hours = dr.work_hours;
worker.hasDailyReport = true;
} else {
workerMap.set(dr.worker_id, {
worker_id: dr.worker_id,
worker_name: dr.worker_name,
overtime_hours: 0,
status: 'normal',
expected_hours: 8,
reported_hours: dr.work_hours,
hasWorkReport: false,
hasDailyReport: true
});
}
});
const result = Array.from(workerMap.values()).map(worker => ({
...worker,
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
validationStatus: getValidationStatus(worker)
}));
console.log(`${dateStr} 작업자 데이터 조합 완료: ${result.length}`);
return result;
} catch (error) {
console.error('데이터 조합 오류:', error);
return [];
}
}
// ========================================
// 이벤트 핸들러 함수들
// ========================================
/**
* 캘린더 날짜 클릭 이벤트 핸들러 (순차 호출)
*/
async function handleDateClick(dateInfo) {
if (!dateInfo.isCurrentMonth) return;
selectedDate = dateInfo.dateStr;
// 선택된 날짜 표시 업데이트
document.querySelectorAll('.calendar-day').forEach(btn => {
btn.classList.remove('selected');
});
const clickedButton = event.target.closest('.calendar-day');
if (clickedButton) {
clickedButton.classList.add('selected');
// 해당 날짜의 상태가 아직 로드되지 않았다면 로드
if (clickedButton.classList.contains('no-data')) {
showMessage(`📊 ${dateInfo.dateStr} 날짜의 상태를 확인하는 중... (순차 호출로 약 5초 소요)`, 'loading');
await loadAndUpdateDateStatus(dateInfo.dateStr, clickedButton);
}
}
// 작업자 데이터 로드 (순차 호출)
showLoadingState();
showMessage(`👥 ${dateInfo.dateStr} 작업자 데이터를 순차적으로 로드 중... (약 5초 소요)`, 'loading');
try {
const workers = await getWorkersForDate(dateInfo.dateStr);
selectedDateWorkers = workers;
renderWorkersList(workers);
showMessage(`${dateInfo.dateStr} 날짜의 데이터를 성공적으로 로드했습니다! (${workers.length}명)`, 'success');
} catch (error) {
console.error('날짜별 데이터 로딩 오류:', error);
showErrorState('해당 날짜의 데이터를 불러올 수 없습니다. 잠시 후 다시 시도해주세요.');
showMessage(`${dateInfo.dateStr} 데이터 로드 실패: ${error.message}`, 'error');
}
}
/**
* 작업자 근무시간 수정 모달 열기
*/
function openEditModal(worker) {
editingWorker = worker;
document.getElementById('editWorkerName').value = worker.worker_name;
document.getElementById('editWorkerStatus').value = getStatusText(worker.status);
document.getElementById('editWorkHours').value = worker.reported_hours || 0;
document.getElementById('editReason').value = '';
document.getElementById('editModal').classList.remove('hidden');
}
/**
* 수정 모달 닫기
*/
function closeEditModal() {
editingWorker = null;
document.getElementById('editModal').classList.add('hidden');
}
/**
* 수정된 작업 저장
*/
async function saveEditedWork() {
if (!editingWorker) return;
try {
const newHours = document.getElementById('editWorkHours').value;
const reason = document.getElementById('editReason').value;
if (!newHours || isNaN(newHours)) {
showMessage('올바른 시간을 입력해주세요.', 'error');
return;
}
showMessage('수정 중...', 'loading');
await updateWorkerHours(editingWorker.worker_id, selectedDate, newHours, reason);
showMessage('✅ 근무시간이 성공적으로 수정되었습니다!', 'success');
closeEditModal();
// 데이터 새로고침
const workers = await getWorkersForDate(selectedDate);
selectedDateWorkers = workers;
renderWorkersList(workers);
renderCalendar();
} catch (error) {
console.error('수정 실패:', error);
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
/**
* 작업자 데이터 삭제
*/
async function deleteWorker(worker) {
if (!confirm(`정말로 ${worker.worker_name}${selectedDate} 작업 데이터를 삭제하시겠습니까?\n삭제된 데이터는 복구할 수 없습니다.`)) {
return;
}
try {
showMessage('삭제 중...', 'loading');
await deleteWorkerReport(worker.worker_id, selectedDate);
showMessage('✅ 작업 데이터가 성공적으로 삭제되었습니다!', 'success');
// 데이터 새로고침
const workers = await getWorkersForDate(selectedDate);
selectedDateWorkers = workers;
renderWorkersList(workers);
renderCalendar();
} catch (error) {
console.error('삭제 실패:', error);
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// ========================================
// UI 렌더링 함수들
// ========================================
/**
* 상태 텍스트 반환
*/
function getStatusText(status) {
const statusMap = {
'normal': '정상출근',
'half_day': '반차',
'vacation': '휴가',
'early_leave': '조퇴',
'quarter_day': '1/4 휴가',
'sick_leave': '병가'
};
return statusMap[status] || status;
}
/**
* 상태 아이콘 반환
*/
function getStatusIcon(status) {
switch (status) {
case 'normal': return '✅';
case 'needs-review': return '⚠️';
case 'missing': return '❌';
default: return '❓';
}
}
/**
* 날짜 포맷팅
*/
function formatDate(dateStr) {
const date = new Date(dateStr);
const month = date.getMonth() + 1;
const day = date.getDate();
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
const weekday = weekdays[date.getDay()];
return `${month}${day}일 (${weekday})`;
}
/**
* 작업자 리스트를 화면에 렌더링합니다
*/
function renderWorkersList(workers) {
const workersList = document.getElementById('workersList');
if (!selectedDate || workers.length === 0) {
workersList.innerHTML = `
<div class="empty-state">
<div class="empty-icon">🔄</div>
<h3 class="empty-title">날짜를 클릭해주세요</h3>
<p class="empty-description">
캘린더에서 날짜를 클릭하면 해당 날짜의 작업자 검증 내역을 확인할 수 있습니다.<br>
<strong>순차 호출 방식</strong>으로 안정적이지만 약 5초의 로딩 시간이 있습니다.
</p>
</div>
`;
return;
}
// 필터링 적용
const filteredWorkers = workers.filter(worker => {
if (currentFilter === 'all') return true;
if (currentFilter === 'needsReview') return worker.validationStatus === 'needs-review';
if (currentFilter === 'normal') return worker.validationStatus === 'normal';
if (currentFilter === 'missing') return worker.validationStatus === 'missing';
return true;
});
// HTML 생성
workersList.innerHTML = `
<div class="fade-in">
<!-- 헤더 -->
<div class="filter-container">
<div>
<h3 class="text-xl font-bold text-gray-800">📋 작업자 검증 현황</h3>
<p class="text-sm text-gray-600 mt-1">${formatDate(selectedDate)}</p>
</div>
<select id="workerFilter" class="filter-select">
<option value="all">전체 보기 (${workers.length}명)</option>
<option value="needsReview">검토필요 (${workers.filter(w => w.validationStatus === 'needs-review').length}명)</option>
<option value="normal">정상 (${workers.filter(w => w.validationStatus === 'normal').length}명)</option>
<option value="missing">미입력 (${workers.filter(w => w.validationStatus === 'missing').length}명)</option>
</select>
</div>
<!-- 작업자 카드 목록 -->
<div class="space-y-4">
${filteredWorkers.map(worker => `
<div class="worker-card ${worker.validationStatus}">
<!-- 작업자 정보 헤더 -->
<div class="worker-header">
<div class="worker-info">
<div class="worker-avatar">
${worker.worker_name.charAt(0)}
</div>
<div>
<div class="worker-name">${worker.worker_name}</div>
<div class="worker-id">작업자 ID: ${worker.worker_id}</div>
</div>
</div>
<div class="status-badge">${getStatusIcon(worker.validationStatus)}</div>
</div>
<!-- 근무시간 정보 -->
<div class="data-section">
<div class="data-row">
<span class="data-label">그룹장 입력</span>
<span class="data-value ${worker.reported_hours === null ? 'text-red-600' : 'text-gray-800'}">
${worker.reported_hours !== null ? `${worker.reported_hours}시간` : '미입력'}
</span>
</div>
<div class="data-row">
<span class="data-label">시스템 계산</span>
<span class="data-value text-gray-800">${worker.expected_hours}시간</span>
</div>
<div class="data-row">
<span class="data-label">근무 상태</span>
<span class="data-value text-gray-600">
${getStatusText(worker.status)}
${worker.overtime_hours > 0 ? ` + 연장 ${worker.overtime_hours}시간` : ''}
</span>
</div>
${worker.difference !== 0 ? `
<div class="data-row pt-2 border-t border-gray-200">
<span class="data-label font-semibold">시간 차이</span>
<span class="data-value font-bold ${worker.difference > 0 ? 'difference-positive' : 'difference-negative'}">
${worker.difference > 0 ? '+' : ''}${worker.difference}시간
</span>
</div>
` : ''}
</div>
<!-- 액션 버튼들 -->
<div class="flex gap-2">
<button onclick="openEditModal(${JSON.stringify(worker).replace(/"/g, '&quot;')})"
class="edit-btn">
✏️ 근무시간 수정
</button>
${worker.hasDailyReport ? `
<button onclick="deleteWorker(${JSON.stringify(worker).replace(/"/g, '&quot;')})"
class="delete-btn">
🗑️ 삭제
</button>
` : ''}
</div>
</div>
`).join('')}
</div>
<!-- 결과 없음 메시지 -->
${filteredWorkers.length === 0 ? `
<div class="empty-state">
<div class="empty-icon">🔍</div>
<h3 class="empty-title">해당 조건의 작업자가 없습니다</h3>
<p class="empty-description">
다른 필터를 선택하거나 전체 보기를 확인해주세요.<br>
또는 해당 날짜에 등록된 작업자가 없을 수 있습니다.
</p>
</div>
` : ''}
</div>
`;
// 필터 이벤트 리스너 추가
const filterSelect = document.getElementById('workerFilter');
filterSelect.value = currentFilter;
filterSelect.addEventListener('change', (e) => {
currentFilter = e.target.value;
renderWorkersList(selectedDateWorkers);
});
}
// ========================================
// 초기화 및 이벤트 리스너 등록
// ========================================
/**
* 페이지 로드 시 초기화 함수
*/
async function init() {
try {
// 인증 확인 (api-config.js의 ensureAuthenticated 대신 직접 확인)
const token = localStorage.getItem('token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
localStorage.removeItem('token');
setTimeout(() => {
window.location.href = '/';
}, 2000);
return;
}
// 월 이동 버튼 이벤트 리스너
document.getElementById('prevMonth').addEventListener('click', async () => {
currentDate.setMonth(currentDate.getMonth() - 1);
selectedDate = null;
dateStatusCache.clear(); // 캐시 클리어
clearCache(); // 추가 캐시 클리어
await renderCalendar();
renderWorkersList([]);
showMessage('📅 이전 달로 이동했습니다. 날짜를 클릭하여 순차적으로 데이터를 확인하세요.', 'success');
});
document.getElementById('nextMonth').addEventListener('click', async () => {
currentDate.setMonth(currentDate.getMonth() + 1);
selectedDate = null;
dateStatusCache.clear(); // 캐시 클리어
clearCache(); // 추가 캐시 클리어
await renderCalendar();
renderWorkersList([]);
showMessage('📅 다음 달로 이동했습니다. 날짜를 클릭하여 순차적으로 데이터를 확인하세요.', 'success');
});
// 모달 이벤트 리스너
document.getElementById('editModal').addEventListener('click', (e) => {
if (e.target.id === 'editModal') {
closeEditModal();
}
});
// 초기 렌더링
await renderCalendar();
// 순차 호출 안내 메시지
showMessage('🔄 API 통합 적용 완료! 순차 호출 시스템으로 안정성이 개선되었습니다.', 'success');
// 전역 함수로 등록
window.openEditModal = openEditModal;
window.closeEditModal = closeEditModal;
window.saveEditedWork = saveEditedWork;
window.deleteWorker = deleteWorker;
console.log('✅ 근태 검증 관리 시스템 초기화 완료 (API 통합)');
console.log(`🔗 API 경로: ${API}`);
console.log(`📊 설정: 동시 최대 ${RATE_LIMIT.maxConcurrent}개 요청, ${RATE_LIMIT.delayBetweenRequests}ms 딜레이`);
console.log('🔄 API 호출 방식: 통합 설정 + 순차 호출');
console.log('🚫 429 에러 방지: 각 날짜당 최소 5초 간격');
} catch (error) {
console.error('초기화 오류:', error);
showMessage('시스템 초기화 중 오류가 발생했습니다.', 'error');
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', init);