// daily-report-viewer.js - 전체 데이터 조회 완전 버전 // ================================================================= // 🔧 API 설정 및 환경 구성 // ================================================================= class APIConfig { constructor() { // daily-work-report.js와 동일한 API 경로 사용 this.possibleBasePaths = [ 'https://api.hyungi.net/api', // 기본 운영 서버 '/api', // 프록시를 통한 API '/api/v1', // API 버전 1 '', // 루트 경로 '/backend/api', // 백엔드 직접 연결 'http://localhost:3005/api' // 로컬 개발 서버 ]; this.currentBasePath = 'https://api.hyungi.net/api'; // 기본값 this.isInitialized = false; // 엔드포인트 정의 (전체 조회용으로 확장) this.endpoints = { DAILY_REPORTS: '/daily-work-reports/date', DAILY_REPORTS_QUERY: '/daily-work-reports', WORK_TYPES: '/daily-work-reports/work-types', WORK_STATUS_TYPES: '/daily-work-reports/work-status-types', ERROR_TYPES: '/daily-work-reports/error-types' }; } // API 기본 경로 자동 감지 async detectBasePath() { if (this.isInitialized && this.currentBasePath) { console.log('🔄 이미 초기화된 경로 사용:', this.currentBasePath); return this.currentBasePath; } console.log('🔍 API 기본 경로 자동 감지 시작...'); for (const basePath of this.possibleBasePaths) { try { console.log(`🧪 테스트 중: ${basePath}`); const testUrl = `${basePath}${this.endpoints.WORK_TYPES}`; const headers = this.getHeaders(); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 8000); const response = await fetch(testUrl, { method: 'GET', headers: headers, signal: controller.signal }); clearTimeout(timeoutId); if (response.ok || response.status === 401) { this.currentBasePath = basePath; this.isInitialized = true; console.log(`✅ API 기본 경로 확정: ${basePath}`); return basePath; } } catch (error) { console.log(`❌ ${basePath} 연결 실패:`, error.message); continue; } } this.currentBasePath = this.possibleBasePaths[0]; this.isInitialized = true; console.log('🔧 기본 경로로 설정:', this.currentBasePath); return this.currentBasePath; } // 토큰 가져오기 (만료 검사 포함) getAuthToken() { const tokens = { token: localStorage.getItem('token'), authToken: localStorage.getItem('authToken'), jwt: localStorage.getItem('jwt') }; const selectedToken = tokens.token || tokens.authToken || tokens.jwt || null; if (selectedToken) { // 토큰 만료 검사 if (this.isTokenExpired(selectedToken)) { console.error('⏰ 토큰이 만료되었습니다.'); this.clearExpiredTokens(); return null; } } return selectedToken; } // JWT 토큰 만료 검사 isTokenExpired(token) { try { const parts = token.split('.'); if (parts.length !== 3) return true; const payload = JSON.parse(atob(parts[1])); if (payload.exp) { const currentTime = Math.floor(Date.now() / 1000); return payload.exp < currentTime; } return false; } catch (error) { return true; } } // 만료된 토큰 정리 clearExpiredTokens() { localStorage.removeItem('token'); localStorage.removeItem('authToken'); localStorage.removeItem('jwt'); localStorage.removeItem('user'); } // 인증 헤더 생성 getHeaders() { const token = this.getAuthToken(); const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; if (token) { headers['Authorization'] = `Bearer ${token}`; } return headers; } // 완전한 URL 생성 getFullUrl(endpoint) { return `${this.currentBasePath}${endpoint}`; } } // ================================================================= // 🌐 향상된 API 클라이언트 // ================================================================= class APIClient { constructor() { this.config = new APIConfig(); this.retryCount = 3; this.retryDelay = 1000; } // 초기화 async initialize() { try { await this.config.detectBasePath(); console.log('✅ API 클라이언트 초기화 완료'); } catch (error) { console.error('❌ API 클라이언트 초기화 실패:', error); throw new Error('API 서버에 연결할 수 없습니다.'); } } // API 호출 async get(endpoint) { const url = this.config.getFullUrl(endpoint); return this.fetchWithRetry(url, { method: 'GET' }); } // 재시도 로직이 포함된 fetch async fetchWithRetry(url, options = {}, attempt = 1) { try { const response = await fetch(url, { ...options, headers: { ...this.config.getHeaders(), ...options.headers } }); if (!response.ok) { await this.handleErrorResponse(response); } const data = await response.json(); return data; } catch (error) { if (attempt < this.retryCount && this.shouldRetry(error)) { await this.sleep(this.retryDelay); return this.fetchWithRetry(url, options, attempt + 1); } throw this.createUserFriendlyError(error); } } // 에러 응답 처리 async handleErrorResponse(response) { let errorMessage = `HTTP ${response.status}: ${response.statusText}`; let errorDetail = null; try { const errorData = await response.json(); errorMessage = errorData.error || errorData.message || errorMessage; errorDetail = errorData.detail || null; } catch (e) { // JSON 파싱 실패는 무시 } if (response.status === 403 && errorDetail === 'jwt expired') { this.handleTokenExpired(); throw new Error('토큰이 만료되었습니다. 다시 로그인해주세요.'); } if (response.status === 401) { this.handleAuthError(); throw new Error('인증이 필요합니다. 다시 로그인해주세요.'); } throw new Error(errorMessage); } // 토큰 만료 처리 handleTokenExpired() { console.warn('⏰ 토큰 만료 처리'); this.config.clearExpiredTokens(); setTimeout(() => { alert('로그인 세션이 만료되었습니다.\n다시 로그인해주세요.'); window.location.href = '/index.html'; }, 1000); } // 인증 오류 처리 handleAuthError() { console.warn('🔐 인증 오류 발생'); this.config.clearExpiredTokens(); setTimeout(() => { if (confirm('인증이 만료되었습니다. 로그인 페이지로 이동하시겠습니까?')) { window.location.href = '/index.html'; } }, 2000); } // 재시도 여부 판단 shouldRetry(error) { return error.name === 'TypeError' || error.message.includes('fetch') || error.message.includes('NetworkError'); } // 사용자 친화적인 에러 메시지 생성 createUserFriendlyError(error) { if (error.name === 'TypeError' && error.message.includes('fetch')) { return new Error('서버에 연결할 수 없습니다. 네트워크 상태를 확인해주세요.'); } return error; } // 지연 함수 sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } // ================================================================= // 🌐 전역 변수 및 상태 관리 // ================================================================= let apiClient = null; let currentReportData = null; let workTypes = []; let workStatusTypes = []; let errorTypes = []; // 캐시 관리 const dataCache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5분 function getCachedData(key) { const cached = dataCache.get(key); if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { return cached.data; } return null; } function setCachedData(key, data) { dataCache.set(key, { data: data, timestamp: Date.now() }); } // ================================================================= // 🔧 유틸리티 함수들 // ================================================================= // 사용자 정보 가져오기 function getUserInfo() { try { const storedUser = localStorage.getItem('user'); if (storedUser) { return JSON.parse(storedUser); } const token = localStorage.getItem('token'); if (token) { const parts = token.split('.'); if (parts.length === 3) { const payload = JSON.parse(atob(parts[1])); return { user_id: payload.user_id || payload.id, username: payload.username, access_level: payload.access_level, name: payload.name }; } } return null; } catch (error) { console.error('❌ 사용자 정보 파싱 오류:', error); return null; } } // ================================================================= // 🚀 초기화 및 이벤트 설정 // ================================================================= document.addEventListener('DOMContentLoaded', async function() { console.log('🔥 ===== 일일보고서 뷰어 시작 ====='); console.log('📍 현재 페이지:', window.location.href); // 사용자 정보 확인 const userInfo = getUserInfo(); console.log('👤 사용자 정보:', userInfo); // 토큰 확인 const mainToken = localStorage.getItem('token'); if (!mainToken) { console.error('❌ 토큰이 없습니다.'); alert('로그인이 필요합니다.'); setTimeout(() => window.location.href = '/index.html', 2000); return; } try { showMessage('시스템을 초기화하는 중...', 'loading'); // API 클라이언트 초기화 apiClient = new APIClient(); await apiClient.initialize(); // 기본 설정 setupEventListeners(); setTodayDate(); // 마스터 데이터 로드 await loadMasterData(); hideMessage(); console.log('✅ 초기화 완료!'); } catch (error) { console.error('❌ 초기화 실패:', error); showError(`초기화 오류: ${error.message}`); if (error.message.includes('인증') || error.message.includes('토큰')) { setTimeout(() => window.location.href = '/index.html', 3000); } } }); function setupEventListeners() { document.getElementById('searchBtn')?.addEventListener('click', searchReports); document.getElementById('todayBtn')?.addEventListener('click', setTodayDate); document.getElementById('reportDate')?.addEventListener('keypress', function(e) { if (e.key === 'Enter') { searchReports(); } }); document.getElementById('exportExcelBtn')?.addEventListener('click', exportToExcel); document.getElementById('printBtn')?.addEventListener('click', printReport); } function setTodayDate() { const today = new Date(); const dateStr = today.toISOString().split('T')[0]; const dateInput = document.getElementById('reportDate'); if (dateInput) { dateInput.value = dateStr; searchReports(); } } // ================================================================= // 📊 마스터 데이터 로드 // ================================================================= async function loadMasterData() { try { console.log('📋 마스터 데이터 로딩...'); const [workTypesRes, workStatusRes, errorTypesRes] = await Promise.allSettled([ loadWorkTypes(), loadWorkStatusTypes(), loadErrorTypes() ]); if (workTypesRes.status === 'fulfilled') { workTypes = workTypesRes.value; } if (workStatusRes.status === 'fulfilled') { workStatusTypes = workStatusRes.value; } if (errorTypesRes.status === 'fulfilled') { errorTypes = errorTypesRes.value; } console.log('✅ 마스터 데이터 로드 완료', { workTypes: workTypes.length, workStatusTypes: workStatusTypes.length, errorTypes: errorTypes.length }); } catch (error) { console.error('❌ 마스터 데이터 로드 실패:', error); } } async function loadWorkTypes() { try { return await apiClient.get(apiClient.config.endpoints.WORK_TYPES); } catch (error) { console.warn('⚠️ 작업 유형 API 실패, 기본값 사용'); return [ {id: 1, name: 'Base'}, {id: 2, name: 'Vessel'}, {id: 3, name: 'Piping'} ]; } } async function loadWorkStatusTypes() { try { return await apiClient.get(apiClient.config.endpoints.WORK_STATUS_TYPES); } catch (error) { console.warn('⚠️ 작업 상태 API 실패, 기본값 사용'); return [ {id: 1, name: '정규'}, {id: 2, name: '에러'} ]; } } async function loadErrorTypes() { try { return await apiClient.get(apiClient.config.endpoints.ERROR_TYPES); } catch (error) { console.warn('⚠️ 에러 유형 API 실패, 기본값 사용'); return [ {id: 1, name: '설계미스'}, {id: 2, name: '외주작업 불량'}, {id: 3, name: '입고지연'}, {id: 4, name: '작업 불량'} ]; } } // ================================================================= // 🔍 검색 및 데이터 조회 (전체 데이터 조회 로직) // ================================================================= async function searchReports() { const selectedDate = document.getElementById('reportDate')?.value; if (!selectedDate) { showError('날짜를 선택해 주세요.'); return; } console.log(`\n🔍 ===== ${selectedDate} 전체 데이터 조회 시작 =====`); // 🔍 먼저 데이터가 실제로 존재하는지 확인 console.log('📋 데이터 존재 여부 사전 확인...'); try { // daily-work-report.js에서 사용하는 방식으로 본인 데이터 확인 const userInfo = getUserInfo(); const myDataUrl = apiClient.config.getFullUrl(`/daily-work-reports?date=${selectedDate}&created_by=${userInfo?.user_id}`); console.log('🔍 본인 데이터 확인:', myDataUrl); const myDataResponse = await fetch(myDataUrl, { method: 'GET', headers: apiClient.config.getHeaders() }); if (myDataResponse.ok) { const myData = await myDataResponse.json(); console.log(`📊 본인 입력 데이터: ${myData.length}개`); if (myData.length === 0) { console.warn('⚠️ 해당 날짜에 입력된 데이터가 전혀 없는 것 같습니다.'); console.log('💡 다른 날짜(6월 24일 등)를 시도해보세요.'); } else { console.log('✅ 데이터 존재 확인됨, 전체 조회 시도 계속...'); } } } catch (error) { console.log('⚠️ 데이터 존재 확인 실패:', error.message); } try { hideAllMessages(); showLoading(true); const userInfo = getUserInfo(); console.log('👤 사용자 권한:', userInfo?.access_level); // 🎯 Rate Limiting 대응: 단계별 API 시도 (한 번에 하나씩) const priorityEndpoints = [ // 가장 유력한 후보들만 우선 시도 `/daily-work-reports/date/${selectedDate}`, `/daily-work-reports?date=${selectedDate}`, `/daily-work-reports?date=${selectedDate}&created_by=`, `/daily-work-reports?date=${selectedDate}&created_by=all`, `/daily-work-reports?date=${selectedDate}&admin=true` ]; console.log(`📡 Rate Limiting 대응: ${priorityEndpoints.length}개 우선 API 시도`); let bestResult = null; let bestEndpoint = null; let maxDataCount = 0; for (let i = 0; i < priorityEndpoints.length; i++) { const endpoint = priorityEndpoints[i]; try { console.log(`\n📡 우선 시도 ${i + 1}/${priorityEndpoints.length}: ${endpoint}`); const fullUrl = apiClient.config.getFullUrl(endpoint); const startTime = Date.now(); const response = await fetch(fullUrl, { method: 'GET', headers: apiClient.config.getHeaders() }); const endTime = Date.now(); console.log(` ⏱️ 응답 시간: ${endTime - startTime}ms`); console.log(` 📊 응답 상태: ${response.status} ${response.statusText}`); if (response.status === 429) { console.warn(` ⚠️ Rate Limit 발생 - 3초 대기 후 재시도`); await this.sleep(3000); // 3초 대기 continue; } if (response.ok) { const data = await response.json(); const dataCount = Array.isArray(data) ? data.length : (data?.workers?.length || 0); console.log(` ✅ 성공! 데이터 개수: ${dataCount}`); if (dataCount > 0) { console.log(` 🎯 데이터 발견! 더 이상 시도하지 않음`); bestResult = data; bestEndpoint = endpoint; maxDataCount = dataCount; break; // 데이터를 찾으면 즉시 중단 } } else { const errorText = await response.text(); console.log(` ❌ 실패 (${response.status}): ${errorText.substring(0, 100)}`); } // API 호출 간 1초 대기 (Rate Limiting 방지) console.log(` ⏳ 1초 대기 중...`); await sleep(1000); } catch (error) { console.log(` ❌ 네트워크 오류: ${error.message}`); await sleep(1000); // 에러 시에도 대기 } } // 유틸리티 함수: sleep function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } console.log(`\n🎯 최종 결과: ${bestEndpoint || '없음'}`); console.log(`📊 최대 데이터 개수: ${maxDataCount}`); if (bestResult && maxDataCount > 0) { console.log(`✅ 전체 데이터 조회 성공!`); // 데이터 구조 변환 const processedData = processRawData(bestResult, selectedDate); currentReportData = processedData; // 화면에 표시 displayReportData(processedData); showExportSection(true); } else { console.log(`❌ 모든 API에서 데이터를 찾을 수 없습니다.`); console.log(`💡 해결 방법:`); console.log(` 1. 다른 날짜 시도 (예: 2025-06-24)`); console.log(` 2. daily-work-report.html에서 데이터 먼저 입력`); console.log(` 3. 백엔드 API에 전체 조회 기능 추가 요청`); // 도움말 메시지와 함께 NoData 표시 showNoDataWithHelp(selectedDate); showExportSection(false); } } catch (error) { console.error('❌ 조회 실패:', error); showError(`데이터 조회 오류: ${error.message}`); showExportSection(false); } finally { showLoading(false); console.log('🔍 ===== 조회 완료 =====\n'); } } // 원시 데이터를 구조화된 형태로 변환 function processRawData(rawData, selectedDate) { console.log('🔄 데이터 구조 변환 시작'); if (!Array.isArray(rawData)) { console.log('📋 이미 구조화된 데이터'); return rawData; } // 작업자별로 그룹화 const workerGroups = {}; let totalHours = 0; let errorCount = 0; rawData.forEach(item => { const workerName = item.worker_name || '미지정'; const workHours = parseFloat(item.work_hours || 0); totalHours += workHours; if (item.work_status_id === 2) { errorCount++; } if (!workerGroups[workerName]) { workerGroups[workerName] = { worker_name: workerName, worker_id: item.worker_id, total_hours: 0, work_entries: [] }; } workerGroups[workerName].total_hours += workHours; workerGroups[workerName].work_entries.push({ project_name: item.project_name, work_type_name: item.work_type_name, work_status_name: item.work_status_name, error_type_name: item.error_type_name, work_hours: workHours, work_status_id: item.work_status_id }); }); const processedData = { summary: { date: selectedDate, total_workers: Object.keys(workerGroups).length, total_hours: totalHours, total_entries: rawData.length, error_count: errorCount }, workers: Object.values(workerGroups) }; console.log('✅ 데이터 변환 완료:', { 작업자수: processedData.workers.length, 총항목수: rawData.length, 총시간: totalHours, 에러수: errorCount }); return processedData; } // ================================================================= // 🎨 UI 표시 함수들 // ================================================================= function displayReportData(data) { console.log('🎨 리포트 데이터 표시'); displaySummary(data.summary); displayWorkersDetails(data.workers); document.getElementById('reportSummary').style.display = 'block'; document.getElementById('workersReport').style.display = 'block'; } function displaySummary(summary) { const elements = { totalWorkers: summary?.total_workers || 0, totalHours: `${summary?.total_hours || 0}시간`, totalEntries: `${summary?.total_entries || 0}개`, errorCount: `${summary?.error_count || 0}개` }; Object.entries(elements).forEach(([id, value]) => { const element = document.getElementById(id); if (element) element.textContent = value; }); // 에러 카드 스타일링 const errorCard = document.querySelector('.summary-card.error-card'); if (errorCard) { const hasErrors = (summary?.error_count || 0) > 0; errorCard.style.borderLeftColor = hasErrors ? '#e74c3c' : '#28a745'; errorCard.style.backgroundColor = hasErrors ? '#fff5f5' : '#f8fff9'; } } function displayWorkersDetails(workers) { const workersList = document.getElementById('workersList'); if (!workersList) return; workersList.innerHTML = ''; workers.forEach(worker => { const workerCard = createWorkerCard(worker); workersList.appendChild(workerCard); }); } function createWorkerCard(worker) { const workerDiv = document.createElement('div'); workerDiv.className = 'worker-card'; const workerHeader = document.createElement('div'); workerHeader.className = 'worker-header'; workerHeader.innerHTML = `
💡 해결 방법: