- 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 오류 없이 모든 기능이 정상 작동합니다.
1501 lines
68 KiB
HTML
1501 lines
68 KiB
HTML
<!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>
|
||
|