Files
M-Project/frontend/issues-inbox.html
hyungi a820a164cb Fix: HTTPS Mixed Content 오류 수정 및 백업 시스템 구축
- Frontend: 하드코딩된 localhost API URL을 동적 URL 생성으로 변경
  - reports-daily.html: 3곳 수정 (프로젝트 로드, 미리보기, 보고서 생성)
  - issues-archive.html: 프로젝트 로드 함수 수정
  - issues-dashboard.html: 2곳 수정 (프로젝트 로드, 진행중 이슈 로드)
  - issues-inbox.html: 프로젝트 로드 함수 수정
  - daily-work.html: 프로젝트 로드 함수 수정
  - permissions.js: 2곳 수정 (권한 부여, 사용자 권한 조회)

- Backup System: 완전한 백업/복구 시스템 구축
  - backup_script.sh: 자동 백업 스크립트 (DB, 볼륨, 설정 파일)
  - restore_script.sh: 백업 복구 스크립트
  - setup_auto_backup.sh: 자동 백업 스케줄 설정 (매일 오후 9시)
  - 백업 정책: 최신 10개 버전만 유지하여 용량 절약

- Migration: 5장 사진 지원 마이그레이션 파일 업데이트

이제 Cloudflare 환경(m.hyungi.net)에서 HTTPS 프로토콜로 API 호출하여
Mixed Content 오류 없이 모든 기능이 정상 작동합니다.
2025-11-13 06:52:21 +09:00

1501 lines
68 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>수신함 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- 모바일 캘린더 스타일 -->
<link rel="stylesheet" href="/static/css/mobile-calendar.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.loading-overlay.active {
opacity: 1;
visibility: visible;
}
.issue-card {
transition: all 0.2s ease;
border-left: 4px solid transparent;
}
.issue-card.unread {
border-left-color: #3b82f6;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
}
.issue-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.priority-high { border-left-color: #ef4444; }
.priority-medium { border-left-color: #f59e0b; }
.priority-low { border-left-color: #10b981; }
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-new { background: #dbeafe; color: #1e40af; }
.badge-processing { background: #fef3c7; color: #92400e; }
.badge-completed { background: #d1fae5; color: #065f46; }
/* 부드러운 페이드인 애니메이션 */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 헤더 전용 빠른 페이드인 */
.header-fade-in {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.header-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 본문 컨텐츠 지연 페이드인 */
.content-fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
transition-delay: 0.2s;
}
.content-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 사진 미리보기 스타일 */
.photo-preview {
max-width: 150px;
max-height: 100px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s ease;
}
.photo-preview:hover {
transform: scale(1.05);
}
.photo-gallery {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.photo-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.photo-modal img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
border-radius: 8px;
}
.photo-modal .close-btn {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
font-size: 18px;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="loading-overlay">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p class="text-gray-600">데이터를 불러오는 중...</p>
</div>
</div>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8 content-fade-in" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-inbox text-blue-500 mr-3"></i>
수신함
</h1>
<p class="text-gray-600 mt-1">새로 등록된 신고 사항을 확인하고 처리하세요</p>
</div>
<div class="flex items-center space-x-3">
<button onclick="refreshInbox()" class="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors">
<i class="fas fa-sync-alt mr-2"></i>
새로고침
</button>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-yellow-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-plus-circle text-yellow-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-yellow-600">금일 신규</p>
<p class="text-2xl font-bold text-yellow-700" id="todayNewCount">0</p>
</div>
</div>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-green-600">금일 처리</p>
<p class="text-2xl font-bold text-green-700" id="todayProcessedCount">0</p>
</div>
</div>
</div>
<div class="bg-red-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-red-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-red-600">미해결</p>
<p class="text-2xl font-bold text-red-700" id="unresolvedCount">0</p>
</div>
</div>
</div>
</div>
</div>
<!-- 필터 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="max-w-md">
<!-- 프로젝트 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📁 프로젝트</label>
<select id="projectFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
</div>
</div>
<!-- 신고 목록 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800">신고 목록</h2>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">정렬:</span>
<select id="sortOrder" class="text-sm border border-gray-300 rounded px-2 py-1" onchange="sortIssues()">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
<option value="priority">우선순위</option>
<option value="unread">읽지 않은 순</option>
</select>
</div>
</div>
</div>
<div id="issuesList" class="divide-y divide-gray-200">
<!-- 부적합 목록이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 빈 상태 -->
<div id="emptyState" class="hidden p-12 text-center">
<i class="fas fa-inbox text-6xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">수신함이 비어있습니다</h3>
<p class="text-gray-500">새로운 신고가 등록되면 여기에 표시됩니다.</p>
</div>
</div>
</main>
<!-- 사진 확대 모달 -->
<div id="photoModal" class="photo-modal hidden" onclick="closePhotoModal()">
<button class="close-btn" onclick="closePhotoModal()">×</button>
<img id="modalPhoto" src="" alt="확대된 사진">
</div>
<!-- 폐기 모달 -->
<div id="disposeModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">부적합 폐기</h3>
<button onclick="closeDisposeModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">폐기 사유</label>
<select id="disposalReason" class="w-full px-3 py-2 border border-gray-300 rounded-lg" onchange="toggleCustomReason(); toggleDuplicateSelection();">
<option value="duplicate">중복 (기본)</option>
<option value="invalid_report">잘못된 신고</option>
<option value="not_applicable">해당 없음</option>
<option value="spam">스팸/오류</option>
<option value="custom">직접 입력</option>
</select>
</div>
<div id="customReasonDiv" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-2">사용자 정의 사유</label>
<textarea id="customReason" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="폐기 사유를 입력하세요..."></textarea>
</div>
<!-- 중복 대상 선택 -->
<div id="duplicateSelectionDiv" class="space-y-3">
<label class="block text-sm font-medium text-gray-700 mb-2">중복 대상 선택</label>
<p class="text-sm text-gray-600 mb-3">동일 프로젝트의 관리함에 있는 이슈 중 중복 대상을 선택하세요:</p>
<div id="managementIssuesList" class="max-h-48 overflow-y-auto border border-gray-200 rounded-lg">
<div class="p-4 text-center text-gray-500">
<i class="fas fa-spinner fa-spin mr-2"></i>관리함 이슈를 불러오는 중...
</div>
</div>
<input type="hidden" id="selectedDuplicateId" value="">
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeDisposeModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
</button>
<button onclick="confirmDispose()" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
폐기
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 검토/수정 모달 -->
<div id="reviewModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">부적합 검토 및 수정</h3>
<button onclick="closeReviewModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div class="space-y-4">
<!-- 원본 정보 표시 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium text-gray-700 mb-2">원본 정보</h4>
<div id="originalInfo" class="text-sm text-gray-600">
<!-- 원본 정보가 여기에 표시됩니다 -->
</div>
</div>
<!-- 수정 폼 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">프로젝트</label>
<select id="reviewProjectId" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="">프로젝트 선택</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
<select id="reviewCategory" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="material_missing">자재 누락</option>
<option value="design_error">설계 오류</option>
<option value="incoming_defect">반입 불량</option>
<option value="inspection_miss">검사 누락</option>
<option value="etc">기타</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">부적합명</label>
<input type="text" id="reviewTitle" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="부적합의 간단한 제목을 입력하세요...">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">상세 내용</label>
<textarea id="reviewDescription" rows="4" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="부적합에 대한 상세한 설명을 입력하세요..."></textarea>
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeReviewModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
</button>
<button onclick="saveReview()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
검토 완료
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 상태 결정 모달 -->
<div id="statusModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">최종 상태 결정</h3>
<button onclick="closeStatusModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">상태 선택</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="radio" name="finalStatus" value="in_progress" class="mr-2" onchange="toggleCompletionPhotoSection()">
<span class="text-sm">🔄 진행 중 (관리함으로 이동)</span>
</label>
<label class="flex items-center">
<input type="radio" name="finalStatus" value="completed" class="mr-2" onchange="toggleCompletionPhotoSection()">
<span class="text-sm">✅ 완료됨 (관리함으로 이동)</span>
</label>
</div>
</div>
<!-- 완료 관련 추가 정보 (완료 상태 선택 시에만 표시) -->
<div id="completionSection" class="hidden space-y-4">
<!-- 완료 사진 업로드 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-camera text-green-500 mr-1"></i>완료 사진 (1장, 선택사항)
</label>
<input type="file" id="completionPhotoInput" accept="image/*"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
onchange="handleCompletionPhotoSelect(event)">
<div id="completionPhotoPreview" class="mt-2 hidden">
<img id="completionPhotoImg" src="" alt="완료 사진 미리보기"
class="w-full max-h-40 object-cover rounded-lg border">
</div>
</div>
<!-- 해결방안 입력 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lightbulb text-yellow-500 mr-1"></i>해결방안 (선택사항)
</label>
<textarea id="solutionInput" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="어떻게 해결하였는지 입력하세요... (빈칸으로 두고 관리함에서 입력해도 됩니다)"></textarea>
</div>
<!-- 해결한 부서 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-building text-blue-500 mr-1"></i>상기 문제를 해결한 부서 (선택사항)
</label>
<select id="responsibleDepartmentInput" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">부서 선택 (관리함에서 입력 가능)</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<!-- 해결한 사람 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-user text-purple-500 mr-1"></i>해결한 사람 (선택사항)
</label>
<input type="text" id="responsiblePersonInput"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="담당자 이름 입력 (관리함에서 입력 가능)">
</div>
<div class="bg-blue-50 p-3 rounded-lg">
<p class="text-xs text-blue-600">
<i class="fas fa-info-circle mr-1"></i>
위 정보들은 선택사항입니다. 빈칸으로 두고 관리함에서 나중에 입력하셔도 됩니다.
</p>
</div>
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeStatusModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
</button>
<button onclick="confirmStatus()" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
상태 변경
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=20250917"></script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script src="/static/js/components/mobile-calendar.js?v=20251025"></script>
<script>
let currentUser = null;
let issues = [];
let projects = [];
let filteredIssues = [];
// 한국 시간(KST) 유틸리티 함수
function getKSTDate(date) {
const utcDate = new Date(date);
// UTC + 9시간 = KST
return new Date(utcDate.getTime() + (9 * 60 * 60 * 1000));
}
function formatKSTDate(date) {
const kstDate = getKSTDate(date);
return kstDate.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
}
function formatKSTTime(date) {
const kstDate = getKSTDate(date);
return kstDate.toLocaleTimeString('ko-KR', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit'
});
}
function getKSTToday() {
const now = new Date();
const kstNow = getKSTDate(now);
return new Date(kstNow.getFullYear(), kstNow.getMonth(), kstNow.getDate());
}
// 애니메이션 함수들
function animateHeaderAppearance() {
console.log('🎨 헤더 애니메이션 시작');
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
if (headerElement) {
headerElement.classList.add('header-fade-in');
setTimeout(() => {
headerElement.classList.add('visible');
console.log('✨ 헤더 페이드인 완료');
// 헤더 애니메이션 완료 후 본문 애니메이션
setTimeout(() => {
animateContentAppearance();
}, 200);
}, 50);
} else {
// 헤더를 찾지 못했으면 바로 본문 애니메이션
console.log('⚠️ 헤더 요소를 찾지 못함 - 본문 애니메이션 시작');
animateContentAppearance();
}
}
// 본문 컨텐츠 애니메이션
function animateContentAppearance() {
console.log('🎨 본문 컨텐츠 애니메이션 시작');
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
const contentElements = document.querySelectorAll('.content-fade-in');
contentElements.forEach((element, index) => {
setTimeout(() => {
element.classList.add('visible');
console.log(`✨ 컨텐츠 ${index + 1} 페이드인 완료`);
}, index * 100); // 100ms씩 지연
});
}
// API 로드 후 초기화 함수
async function initializeInbox() {
console.log('🚀 수신함 초기화 시작');
const token = localStorage.getItem('access_token');
console.log('토큰 존재:', !!token);
if (!token) {
console.log('❌ 토큰 없음 - 로그인 페이지로 이동');
window.location.href = '/index.html';
return;
}
try {
console.log('📡 사용자 정보 API 호출 시작');
const user = await AuthAPI.getCurrentUser();
console.log('✅ 사용자 정보 로드 성공:', user);
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
// 공통 헤더 초기화
await window.commonHeader.init(user, 'issues_inbox');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
// 페이지 접근 권한 체크
setTimeout(() => {
console.log('🔐 수신함 페이지 접근 권한 체크 시작');
console.log('현재 사용자:', currentUser);
console.log('canAccessPage 함수 존재:', typeof canAccessPage);
if (typeof canAccessPage === 'function') {
const hasAccess = canAccessPage('issues_inbox');
console.log('수신함 접근 권한:', hasAccess);
if (!hasAccess) {
console.log('❌ 수신함 접근 권한 없음 - 메인 페이지로 이동');
alert('수신함 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
} else {
console.log('✅ 수신함 접근 권한 확인됨');
}
} else {
console.log('⚠️ canAccessPage 함수가 로드되지 않음 - 권한 체크 스킵');
}
}, 500);
// 데이터 로드
await loadProjects();
await loadIssues();
// loadIssues()에서 이미 loadStatistics() 호출함
} catch (error) {
console.error('수신함 초기화 실패:', error);
// 401 Unauthorized 에러인 경우만 로그아웃 처리
if (error.message && (error.message.includes('401') || error.message.includes('Unauthorized') || error.message.includes('Not authenticated'))) {
console.log('🔐 인증 토큰 만료 - 로그아웃 처리');
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
} else {
// 다른 에러는 사용자에게 알리고 계속 진행
console.log('⚠️ 데이터 로드 실패 - 빈 상태로 표시');
alert('일부 데이터를 불러오는데 실패했습니다. 새로고침 후 다시 시도해주세요.');
// 공통 헤더만이라도 초기화
try {
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (user.id) {
await window.commonHeader.init(user, 'issues_inbox');
// 에러 상황에서도 애니메이션 적용
setTimeout(() => {
animateHeaderAppearance();
}, 100);
}
} catch (headerError) {
console.error('공통 헤더 초기화 실패:', headerError);
}
}
}
}
// 프로젝트 로드
async function loadProjects() {
console.log('🔄 프로젝트 로드 시작');
try {
const apiUrl = window.API_BASE_URL || (() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
return `${protocol}//${hostname}:${port}/api`;
}
if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
}
return '/api';
})();
// ProjectsAPI 사용 (모든 프로젝트 로드)
projects = await ProjectsAPI.getAll(false);
console.log('✅ 프로젝트 로드 성공:', projects.length, '개');
console.log('📋 프로젝트 목록:', projects);
updateProjectFilter();
} catch (error) {
console.error('❌ 프로젝트 로드 실패:', error);
}
}
// 프로젝트 필터 업데이트
function updateProjectFilter() {
const projectFilter = document.getElementById('projectFilter');
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name;
projectFilter.appendChild(option);
});
}
// 수신함 부적합 목록 로드 (실제 API 연동)
async function loadIssues() {
showLoading(true);
try {
const projectId = document.getElementById('projectFilter').value;
let url = '/api/inbox/';
// 프로젝트 필터 적용
if (projectId) {
url += `?project_id=${projectId}`;
}
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
issues = await response.json();
filterIssues();
await loadStatistics();
} else {
throw new Error('수신함 목록을 불러올 수 없습니다.');
}
} catch (error) {
console.error('수신함 로드 실패:', error);
showError('수신함 목록을 불러오는데 실패했습니다.');
} finally {
showLoading(false);
}
}
// 신고 필터링
function filterIssues() {
const projectFilter = document.getElementById('projectFilter').value;
filteredIssues = issues.filter(issue => {
// 프로젝트 필터
if (projectFilter && issue.project_id != projectFilter) return false;
return true;
});
sortIssues();
displayIssues();
}
// 신고 정렬
function sortIssues() {
const sortOrder = document.getElementById('sortOrder').value;
filteredIssues.sort((a, b) => {
switch (sortOrder) {
case 'newest':
return new Date(b.report_date) - new Date(a.report_date);
case 'oldest':
return new Date(a.report_date) - new Date(b.report_date);
case 'priority':
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
return (priorityOrder[b.priority] || 1) - (priorityOrder[a.priority] || 1);
default:
return new Date(b.report_date) - new Date(a.report_date);
}
});
}
// 부적합 목록 표시
function displayIssues() {
const container = document.getElementById('issuesList');
const emptyState = document.getElementById('emptyState');
if (filteredIssues.length === 0) {
container.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
container.innerHTML = filteredIssues.map(issue => {
const project = projects.find(p => p.id === issue.project_id);
const reportDate = new Date(issue.report_date);
const createdDate = formatKSTDate(reportDate);
const createdTime = formatKSTTime(reportDate);
const timeAgo = getTimeAgo(reportDate);
// 사진 정보 처리
const photoCount = [issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean).length;
const photoInfo = photoCount > 0 ? `사진 ${photoCount}` : '사진 없음';
return `
<div class="issue-card p-6 hover:bg-gray-50 border-l-4 border-blue-500"
data-issue-id="${issue.id}">
<div class="flex items-start justify-between">
<div class="flex-1">
<!-- 상단 정보 -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<span class="badge badge-new">검토 대기</span>
${project ? `<span class="text-sm font-medium text-blue-600">${project.project_name}</span>` : '<span class="text-sm text-gray-400">프로젝트 미지정</span>'}
</div>
<span class="text-xs text-gray-400">ID: ${issue.id}</span>
</div>
<!-- 제목 -->
<h3 class="text-lg font-semibold text-gray-900 mb-3 cursor-pointer hover:text-blue-600 transition-colors" onclick="viewIssueDetail(${issue.id})">${issue.final_description || issue.description}</h3>
<!-- 상세 정보 그리드 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4 text-sm">
<div class="flex items-center text-gray-600">
<i class="fas fa-user mr-2 text-blue-500"></i>
<span class="font-medium">${issue.reporter?.username || '알 수 없음'}</span>
</div>
<div class="flex items-center text-gray-600">
<i class="fas fa-tag mr-2 text-green-500"></i>
<span>${getCategoryText(issue.category || issue.final_category)}</span>
</div>
<div class="flex items-center text-gray-600">
<i class="fas fa-camera mr-2 text-purple-500"></i>
<span class="${photoCount > 0 ? 'text-purple-600 font-medium' : ''}">${photoInfo}</span>
</div>
<div class="flex items-center text-gray-600">
<i class="fas fa-clock mr-2 text-orange-500"></i>
<span class="font-medium">${timeAgo}</span>
</div>
</div>
<!-- 업로드 시간 정보 -->
<div class="bg-gray-50 rounded-lg p-3 mb-4">
<div class="flex items-center justify-between text-sm">
<div class="flex items-center text-gray-600">
<i class="fas fa-calendar-alt mr-2"></i>
<span>업로드: <strong>${createdDate} ${createdTime}</strong></span>
</div>
${issue.work_hours > 0 ? `<div class="flex items-center text-gray-600">
<i class="fas fa-hourglass-half mr-2"></i>
<span>공수: <strong>${issue.work_hours}시간</strong></span>
</div>` : ''}
</div>
${issue.detail_notes ? `<div class="mt-2 text-sm text-gray-600">
<i class="fas fa-sticky-note mr-2"></i>
<span class="italic">"${issue.detail_notes}"</span>
</div>` : ''}
</div>
<!-- 사진 미리보기 -->
${photoCount > 0 ? `
<div class="photo-gallery">
${[issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5]
.filter(Boolean)
.map((path, idx) => `<img src="${path}" class="photo-preview" onclick="openPhotoModal('${path}')" alt="첨부 사진 ${idx + 1}">`)
.join('')}
</div>
` : ''}
<!-- 워크플로우 액션 버튼들 -->
<div class="flex items-center space-x-2 mt-3">
<button onclick="openDisposeModal(${issue.id})"
class="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-1"></i>폐기
</button>
<button onclick="openReviewModal(${issue.id})"
class="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600 transition-colors">
<i class="fas fa-edit mr-1"></i>검토
</button>
<button onclick="openStatusModal(${issue.id})"
class="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 transition-colors">
<i class="fas fa-check mr-1"></i>확인
</button>
</div>
</div>
</div>
</div>
`;
}).join('');
}
// 통계 로드 (새로운 기준)
async function loadStatistics() {
try {
// 현재 수신함 이슈들을 기반으로 통계 계산 (KST 기준)
const todayStart = getKSTToday();
console.log('📅 KST 기준 오늘 시작:', todayStart);
// 금일 신규: 오늘 올라온 목록 숫자 (확인된 것 포함) - KST 기준
const todayNewCount = issues.filter(issue => {
const reportDate = getKSTDate(new Date(issue.report_date));
const reportDateOnly = new Date(reportDate.getFullYear(), reportDate.getMonth(), reportDate.getDate());
return reportDateOnly >= todayStart;
}).length;
// 금일 처리: 오늘 처리된 건수 (API에서 가져와야 함)
let todayProcessedCount = 0;
try {
const processedResponse = await fetch('/api/inbox/statistics', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
}
});
if (processedResponse.ok) {
const stats = await processedResponse.json();
todayProcessedCount = stats.today_processed || 0;
}
} catch (e) {
console.log('처리된 건수 조회 실패:', e);
}
// 미해결: 오늘꺼 제외한 남아있는 것들 - KST 기준
const unresolvedCount = issues.filter(issue => {
const reportDate = getKSTDate(new Date(issue.report_date));
const reportDateOnly = new Date(reportDate.getFullYear(), reportDate.getMonth(), reportDate.getDate());
return reportDateOnly < todayStart;
}).length;
// 통계 업데이트
document.getElementById('todayNewCount').textContent = todayNewCount;
document.getElementById('todayProcessedCount').textContent = todayProcessedCount;
document.getElementById('unresolvedCount').textContent = unresolvedCount;
console.log('📊 통계 업데이트 (KST 기준):', {
금일신규: todayNewCount,
금일처리: todayProcessedCount,
미해결: unresolvedCount,
기준일: formatKSTDate(new Date())
});
} catch (error) {
console.error('통계 로드 오류:', error);
// 오류 시 기본값 설정
document.getElementById('todayNewCount').textContent = '0';
document.getElementById('todayProcessedCount').textContent = '0';
document.getElementById('unresolvedCount').textContent = '0';
}
}
// 새로고침
function refreshInbox() {
loadIssues();
}
// 신고 상세 보기
function viewIssueDetail(issueId) {
window.location.href = `/issue-view.html#detail-${issueId}`;
}
// 사진 모달 관련 함수들
function openPhotoModal(photoPath) {
const modal = document.getElementById('photoModal');
const modalPhoto = document.getElementById('modalPhoto');
modalPhoto.src = photoPath;
modal.classList.remove('hidden');
// ESC 키로 모달 닫기
document.addEventListener('keydown', handleEscKey);
}
function closePhotoModal() {
const modal = document.getElementById('photoModal');
modal.classList.add('hidden');
// ESC 키 이벤트 제거
document.removeEventListener('keydown', handleEscKey);
}
function handleEscKey(event) {
if (event.key === 'Escape') {
closePhotoModal();
}
}
// ===== 워크플로우 모달 관련 함수들 =====
let currentIssueId = null;
// 폐기 모달 열기
function openDisposeModal(issueId) {
currentIssueId = issueId;
document.getElementById('disposalReason').value = 'duplicate';
document.getElementById('customReason').value = '';
document.getElementById('customReasonDiv').classList.add('hidden');
document.getElementById('selectedDuplicateId').value = '';
document.getElementById('disposeModal').classList.remove('hidden');
// 중복 선택 영역 표시 (기본값이 duplicate이므로)
toggleDuplicateSelection();
}
// 폐기 모달 닫기
function closeDisposeModal() {
currentIssueId = null;
document.getElementById('disposeModal').classList.add('hidden');
}
// 사용자 정의 사유 토글
function toggleCustomReason() {
const reason = document.getElementById('disposalReason').value;
const customDiv = document.getElementById('customReasonDiv');
if (reason === 'custom') {
customDiv.classList.remove('hidden');
} else {
customDiv.classList.add('hidden');
}
}
// 중복 대상 선택 토글
function toggleDuplicateSelection() {
const reason = document.getElementById('disposalReason').value;
const duplicateDiv = document.getElementById('duplicateSelectionDiv');
if (reason === 'duplicate') {
duplicateDiv.classList.remove('hidden');
loadManagementIssues();
} else {
duplicateDiv.classList.add('hidden');
document.getElementById('selectedDuplicateId').value = '';
}
}
// 관리함 이슈 목록 로드
async function loadManagementIssues() {
const currentIssue = issues.find(issue => issue.id === currentIssueId);
const projectId = currentIssue ? currentIssue.project_id : null;
try {
const response = await fetch(`/api/inbox/management-issues${projectId ? `?project_id=${projectId}` : ''}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (!response.ok) {
throw new Error('관리함 이슈 목록을 불러올 수 없습니다.');
}
const managementIssues = await response.json();
displayManagementIssues(managementIssues);
} catch (error) {
console.error('관리함 이슈 로드 오류:', error);
document.getElementById('managementIssuesList').innerHTML = `
<div class="p-4 text-center text-red-500">
<i class="fas fa-exclamation-triangle mr-2"></i>이슈 목록을 불러올 수 없습니다.
</div>
`;
}
}
// 관리함 이슈 목록 표시
function displayManagementIssues(managementIssues) {
const container = document.getElementById('managementIssuesList');
if (managementIssues.length === 0) {
container.innerHTML = `
<div class="p-4 text-center text-gray-500">
<i class="fas fa-inbox mr-2"></i>동일 프로젝트의 관리함 이슈가 없습니다.
</div>
`;
return;
}
container.innerHTML = managementIssues.map(issue => `
<div class="p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer"
onclick="selectDuplicateTarget(${issue.id}, this)">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="text-sm font-medium text-gray-900 mb-1">
${issue.description || issue.final_description}
</div>
<div class="flex items-center gap-2 text-xs text-gray-500">
<span class="px-2 py-1 bg-gray-100 rounded">${getCategoryText(issue.category || issue.final_category)}</span>
<span>신고자: ${issue.reporter_name}</span>
${issue.duplicate_count > 0 ? `<span class="text-orange-600">중복 ${issue.duplicate_count}건</span>` : ''}
</div>
</div>
<div class="text-xs text-gray-400">
ID: ${issue.id}
</div>
</div>
</div>
`).join('');
}
// 중복 대상 선택
function selectDuplicateTarget(issueId, element) {
// 이전 선택 해제
document.querySelectorAll('#managementIssuesList > div').forEach(div => {
div.classList.remove('bg-blue-50', 'border-blue-200');
});
// 현재 선택 표시
element.classList.add('bg-blue-50', 'border-blue-200');
document.getElementById('selectedDuplicateId').value = issueId;
}
// 폐기 확인
async function confirmDispose() {
if (!currentIssueId) return;
const disposalReason = document.getElementById('disposalReason').value;
const customReason = document.getElementById('customReason').value;
const duplicateId = document.getElementById('selectedDuplicateId').value;
// 사용자 정의 사유 검증
if (disposalReason === 'custom' && !customReason.trim()) {
alert('사용자 정의 폐기 사유를 입력해주세요.');
return;
}
// 중복 대상 선택 검증
if (disposalReason === 'duplicate' && !duplicateId) {
alert('중복 대상을 선택해주세요.');
return;
}
try {
const requestBody = {
disposal_reason: disposalReason,
custom_disposal_reason: disposalReason === 'custom' ? customReason : null
};
// 중복 처리인 경우 대상 ID 추가
if (disposalReason === 'duplicate' && duplicateId) {
requestBody.duplicate_of_issue_id = parseInt(duplicateId);
}
const response = await fetch(`/api/inbox/${currentIssueId}/dispose`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (response.ok) {
const result = await response.json();
const message = disposalReason === 'duplicate'
? '중복 신고가 처리되었습니다.\n신고자 정보가 원본 이슈에 추가되었습니다.'
: `부적합이 성공적으로 폐기되었습니다.\n사유: ${getDisposalReasonText(disposalReason)}`;
alert(message);
closeDisposeModal();
await loadIssues(); // 목록 새로고침
} else {
const error = await response.json();
throw new Error(error.detail || '폐기 처리에 실패했습니다.');
}
} catch (error) {
console.error('폐기 처리 오류:', error);
alert('폐기 처리 중 오류가 발생했습니다: ' + error.message);
}
}
// 검토 모달 열기
async function openReviewModal(issueId) {
currentIssueId = issueId;
// 현재 부적합 정보 찾기
const issue = issues.find(i => i.id === issueId);
if (!issue) return;
// 원본 정보 표시
const originalInfo = document.getElementById('originalInfo');
const project = projects.find(p => p.id === issue.project_id);
originalInfo.innerHTML = `
<div class="space-y-2">
<div><strong>프로젝트:</strong> ${project ? project.project_name : '미지정'}</div>
<div><strong>카테고리:</strong> ${getCategoryText(issue.category || issue.final_category)}</div>
<div><strong>설명:</strong> ${issue.description || issue.final_description}</div>
<div><strong>등록자:</strong> ${issue.reporter?.username || '알 수 없음'}</div>
<div><strong>등록일:</strong> ${new Date(issue.report_date).toLocaleDateString('ko-KR')}</div>
</div>
`;
// 프로젝트 옵션 업데이트
const reviewProjectSelect = document.getElementById('reviewProjectId');
reviewProjectSelect.innerHTML = '<option value="">프로젝트 선택</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name;
if (project.id === issue.project_id) {
option.selected = true;
}
reviewProjectSelect.appendChild(option);
});
// 현재 값들로 폼 초기화 (최신 내용 우선 사용)
document.getElementById('reviewCategory').value = issue.category || issue.final_category;
// 최신 description을 title과 description으로 분리 (첫 번째 줄을 title로 사용)
const currentDescription = issue.description || issue.final_description;
const lines = currentDescription.split('\n');
document.getElementById('reviewTitle').value = lines[0] || '';
document.getElementById('reviewDescription').value = lines.slice(1).join('\n') || currentDescription;
document.getElementById('reviewModal').classList.remove('hidden');
}
// 검토 모달 닫기
function closeReviewModal() {
currentIssueId = null;
document.getElementById('reviewModal').classList.add('hidden');
}
// 검토 저장
async function saveReview() {
if (!currentIssueId) return;
const projectId = document.getElementById('reviewProjectId').value;
const category = document.getElementById('reviewCategory').value;
const title = document.getElementById('reviewTitle').value.trim();
const description = document.getElementById('reviewDescription').value.trim();
if (!title) {
alert('부적합명을 입력해주세요.');
return;
}
// 부적합명과 상세 내용을 합쳐서 저장 (첫 번째 줄에 제목, 나머지는 상세 내용)
const combinedDescription = title + (description ? '\n' + description : '');
try {
const response = await fetch(`/api/inbox/${currentIssueId}/review`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
project_id: projectId ? parseInt(projectId) : null,
category: category,
description: combinedDescription
})
});
if (response.ok) {
const result = await response.json();
alert(`검토가 완료되었습니다.\n수정된 항목: ${result.modifications_count}`);
closeReviewModal();
await loadIssues(); // 목록 새로고침
} else {
const error = await response.json();
throw new Error(error.detail || '검토 처리에 실패했습니다.');
}
} catch (error) {
console.error('검토 처리 오류:', error);
alert('검토 처리 중 오류가 발생했습니다: ' + error.message);
}
}
// 상태 모달 열기
function openStatusModal(issueId) {
currentIssueId = issueId;
// 라디오 버튼 초기화
document.querySelectorAll('input[name="finalStatus"]').forEach(radio => {
radio.checked = false;
});
document.getElementById('statusModal').classList.remove('hidden');
}
// 상태 모달 닫기
function closeStatusModal() {
currentIssueId = null;
document.getElementById('statusModal').classList.add('hidden');
// 완료 관련 필드 초기화
document.getElementById('completionSection').classList.add('hidden');
document.getElementById('completionPhotoInput').value = '';
document.getElementById('completionPhotoPreview').classList.add('hidden');
document.getElementById('solutionInput').value = '';
document.getElementById('responsibleDepartmentInput').value = '';
document.getElementById('responsiblePersonInput').value = '';
completionPhotoBase64 = null;
}
// 완료 섹션 토글
function toggleCompletionPhotoSection() {
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
const completionSection = document.getElementById('completionSection');
if (selectedStatus && selectedStatus.value === 'completed') {
completionSection.classList.remove('hidden');
} else {
completionSection.classList.add('hidden');
// 완료 관련 필드 초기화
document.getElementById('completionPhotoInput').value = '';
document.getElementById('completionPhotoPreview').classList.add('hidden');
document.getElementById('solutionInput').value = '';
document.getElementById('responsibleDepartmentInput').value = '';
document.getElementById('responsiblePersonInput').value = '';
completionPhotoBase64 = null;
}
}
// 완료 사진 선택 처리
let completionPhotoBase64 = null;
function handleCompletionPhotoSelect(event) {
const file = event.target.files[0];
if (!file) {
completionPhotoBase64 = null;
document.getElementById('completionPhotoPreview').classList.add('hidden');
return;
}
// 파일 크기 체크 (5MB 제한)
if (file.size > 5 * 1024 * 1024) {
alert('파일 크기는 5MB 이하여야 합니다.');
event.target.value = '';
return;
}
// 이미지 파일인지 확인
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 가능합니다.');
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(e) {
completionPhotoBase64 = e.target.result.split(',')[1]; // Base64 부분만 추출
// 미리보기 표시
document.getElementById('completionPhotoImg').src = e.target.result;
document.getElementById('completionPhotoPreview').classList.remove('hidden');
};
reader.readAsDataURL(file);
}
// 상태 변경 확인
async function confirmStatus() {
if (!currentIssueId) return;
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
if (!selectedStatus) {
alert('상태를 선택해주세요.');
return;
}
const reviewStatus = selectedStatus.value;
try {
const requestBody = {
review_status: reviewStatus
};
// 완료 상태일 때 추가 정보 수집
if (reviewStatus === 'completed') {
// 완료 사진
if (completionPhotoBase64) {
requestBody.completion_photo = completionPhotoBase64;
}
// 해결방안
const solution = document.getElementById('solutionInput').value.trim();
if (solution) {
requestBody.solution = solution;
}
// 담당부서
const responsibleDepartment = document.getElementById('responsibleDepartmentInput').value;
if (responsibleDepartment) {
requestBody.responsible_department = responsibleDepartment;
}
// 담당자
const responsiblePerson = document.getElementById('responsiblePersonInput').value.trim();
if (responsiblePerson) {
requestBody.responsible_person = responsiblePerson;
}
}
const response = await fetch(`/api/inbox/${currentIssueId}/status`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (response.ok) {
const result = await response.json();
alert(`상태가 성공적으로 변경되었습니다.\n${result.destination}으로 이동됩니다.`);
closeStatusModal();
await loadIssues(); // 목록 새로고침
} else {
const error = await response.json();
throw new Error(error.detail || '상태 변경에 실패했습니다.');
}
} catch (error) {
console.error('상태 변경 오류:', error);
alert('상태 변경 중 오류가 발생했습니다: ' + error.message);
}
}
// 유틸리티 함수들
function getStatusBadgeClass(status) {
const statusMap = {
'new': 'new',
'processing': 'processing',
'completed': 'completed',
'pending': 'processing'
};
return statusMap[status] || 'new';
}
function getStatusText(status) {
const statusMap = {
'new': '새 부적합',
'processing': '처리 중',
'completed': '완료',
'pending': '대기 중'
};
return statusMap[status] || status;
}
function getCategoryText(category) {
const categoryMap = {
'material_missing': '자재 누락',
'design_error': '설계 오류',
'incoming_defect': '반입 불량',
'inspection_miss': '검사 누락',
'etc': '기타'
};
return categoryMap[category] || category;
}
function getDisposalReasonText(reason) {
const reasonMap = {
'duplicate': '중복',
'invalid_report': '잘못된 신고',
'not_applicable': '해당 없음',
'spam': '스팸/오류',
'custom': '직접 입력'
};
return reasonMap[reason] || reason;
}
function getTimeAgo(date) {
const now = getKSTDate(new Date());
const kstDate = getKSTDate(date);
const diffMs = now - kstDate;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return formatKSTDate(date);
}
function showLoading(show) {
const overlay = document.getElementById('loadingOverlay');
if (show) {
overlay.classList.add('active');
} else {
overlay.classList.remove('active');
}
}
function showError(message) {
alert(message);
}
// API 스크립트 동적 로딩
const cacheBuster = Date.now() + Math.random() + Math.floor(Math.random() * 1000000);
const script = document.createElement('script');
script.src = `/static/js/api.js?v=20251025-2&cb=${cacheBuster}&t=${Date.now()}&r=${Math.random()}`;
script.setAttribute('cache-control', 'no-cache');
script.setAttribute('pragma', 'no-cache');
script.onload = function() {
console.log('✅ API 스크립트 로드 완료 (issues-inbox.html)');
initializeInbox();
};
script.onerror = function() {
console.error('❌ API 스크립트 로드 실패');
};
document.head.appendChild(script);
</script>
</body>
</html>