1038 lines
36 KiB
JavaScript
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, '"')})"
|
|
class="edit-btn">
|
|
✏️ 근무시간 수정
|
|
</button>
|
|
${worker.hasDailyReport ? `
|
|
<button onclick="deleteWorker(${JSON.stringify(worker).replace(/"/g, '"')})"
|
|
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); |