refactor: 코드 분리 + 성능 최적화 + 모바일 개선
tkqc 5개 페이지 인라인 JS/CSS를 외부 파일로 추출 (HTML 82% 감소) tkuser index.html을 CSS 1개 + JS 10개 모듈로 분리 (3283→1155줄) - 공통 유틸 추출: issue-helpers, photo-modal, toast - 공통 CSS 확장: tkqc-common.css (모바일 반응형 포함) - 모바일 하단 네비게이션 추가 (mobile-bottom-nav.js) - nginx: JS/CSS 1시간 캐싱 + gzip 압축 활성화 - Tailwind CDN preload, 캐시버스터 통일 (?v=20260213) - 카메라 capture="environment" 추가 - tkuser Dockerfile에 static/ 디렉토리 복사 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
888
system3-nonconformance/web/static/js/pages/issues-inbox.js
Normal file
888
system3-nonconformance/web/static/js/pages/issues-inbox.js
Normal file
@@ -0,0 +1,888 @@
|
||||
/**
|
||||
* issues-inbox.js — 수신함 페이지 스크립트
|
||||
*/
|
||||
|
||||
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() {
|
||||
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
|
||||
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');
|
||||
|
||||
// 헤더 애니메이션 완료 후 본문 애니메이션
|
||||
setTimeout(() => {
|
||||
animateContentAppearance();
|
||||
}, 200);
|
||||
}, 50);
|
||||
} else {
|
||||
// 헤더를 찾지 못했으면 바로 본문 애니메이션
|
||||
animateContentAppearance();
|
||||
}
|
||||
}
|
||||
|
||||
// 본문 컨텐츠 애니메이션
|
||||
function animateContentAppearance() {
|
||||
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
|
||||
const contentElements = document.querySelectorAll('.content-fade-in');
|
||||
|
||||
contentElements.forEach((element, index) => {
|
||||
setTimeout(() => {
|
||||
element.classList.add('visible');
|
||||
}, index * 100); // 100ms씩 지연
|
||||
});
|
||||
}
|
||||
|
||||
// API 로드 후 초기화 함수
|
||||
async function initializeInbox() {
|
||||
console.log('수신함 초기화 시작');
|
||||
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
if (!token) {
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await AuthAPI.getCurrentUser();
|
||||
currentUser = user;
|
||||
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||
|
||||
// 공통 헤더 초기화
|
||||
await window.commonHeader.init(user, 'issues_inbox');
|
||||
|
||||
// 헤더 초기화 후 부드러운 애니메이션 시작
|
||||
setTimeout(() => {
|
||||
animateHeaderAppearance();
|
||||
}, 100);
|
||||
|
||||
// 페이지 접근 권한 체크
|
||||
setTimeout(() => {
|
||||
if (typeof canAccessPage === 'function') {
|
||||
const hasAccess = canAccessPage('issues_inbox');
|
||||
|
||||
if (!hasAccess) {
|
||||
alert('수신함 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, 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'))) {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = '/index.html';
|
||||
} else {
|
||||
// 다른 에러는 사용자에게 알리고 계속 진행
|
||||
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() {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
projects = await response.json();
|
||||
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 ${TokenManager.getToken()}`,
|
||||
'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();
|
||||
|
||||
// 금일 신규: 오늘 올라온 목록 숫자 (확인된 것 포함) - 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 ${TokenManager.getToken()}`,
|
||||
'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;
|
||||
|
||||
} 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}`;
|
||||
}
|
||||
|
||||
// openPhotoModal, closePhotoModal, handleEscKey는 photo-modal.js에서 제공됨
|
||||
|
||||
// ===== 워크플로우 모달 관련 함수들 =====
|
||||
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 ${TokenManager.getToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
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 ${TokenManager.getToken()}`,
|
||||
'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 ${TokenManager.getToken()}`,
|
||||
'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 ${TokenManager.getToken()}`,
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
// getStatusBadgeClass, getStatusText, getCategoryText, getDisposalReasonText는
|
||||
// issue-helpers.js에서 제공됨
|
||||
|
||||
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 script = document.createElement('script');
|
||||
script.src = '/static/js/api.js?v=20260213';
|
||||
script.onload = function() {
|
||||
console.log('API 스크립트 로드 완료 (issues-inbox.html)');
|
||||
initializeInbox();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error('API 스크립트 로드 실패');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
Reference in New Issue
Block a user