- ai-service: Ollama 기반 AI 서비스 (분류, 시맨틱 검색, RAG Q&A, 패턴 분석) - AI 어시스턴트 페이지: 채팅형 Q&A, 시맨틱 검색, 패턴 분석, 분류 테스트 - 권한 시스템에 ai_assistant 페이지 등록 (기본 비활성) - 기존 페이지에 AI 기능 통합 (대시보드, 수신함, 관리함) - docker-compose, gateway, nginx 설정 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
960 lines
36 KiB
JavaScript
960 lines
36 KiB
JavaScript
/**
|
|
* 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>
|
|
${issue.location_info ? `<div class="flex items-center text-gray-600">
|
|
<i class="fas fa-map-marker-alt mr-2 text-red-500"></i>
|
|
<span>${issue.location_info}</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);
|
|
}
|
|
|
|
// AI 분류 추천
|
|
async function aiClassifyCurrentIssue() {
|
|
if (!currentIssueId || typeof AiAPI === 'undefined') return;
|
|
const issue = issues.find(i => i.id === currentIssueId);
|
|
if (!issue) return;
|
|
|
|
const btn = document.getElementById('aiClassifyBtn');
|
|
const loading = document.getElementById('aiClassifyLoading');
|
|
const result = document.getElementById('aiClassifyResult');
|
|
if (btn) btn.disabled = true;
|
|
if (loading) loading.classList.remove('hidden');
|
|
if (result) result.classList.add('hidden');
|
|
|
|
// RAG 강화 분류 사용 (과거 사례 참고)
|
|
const classifyFn = AiAPI.classifyWithRAG || AiAPI.classifyIssue;
|
|
const data = await classifyFn(
|
|
issue.description || issue.final_description || '',
|
|
issue.detail_notes || ''
|
|
);
|
|
|
|
if (loading) loading.classList.add('hidden');
|
|
if (btn) btn.disabled = false;
|
|
|
|
if (!data.available) {
|
|
if (result) {
|
|
result.innerHTML = '<p class="text-xs text-red-500">AI 서비스를 사용할 수 없습니다</p>';
|
|
result.classList.remove('hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const categoryMap = {
|
|
'material_missing': '자재 누락',
|
|
'design_error': '설계 오류',
|
|
'incoming_defect': '반입 불량',
|
|
'inspection_miss': '검사 누락',
|
|
};
|
|
const deptMap = {
|
|
'production': '생산',
|
|
'quality': '품질',
|
|
'purchasing': '구매',
|
|
'design': '설계',
|
|
'sales': '영업',
|
|
};
|
|
|
|
const cat = data.category || '';
|
|
const dept = data.responsible_department || '';
|
|
const severity = data.severity || '';
|
|
const summary = data.summary || '';
|
|
const confidence = data.category_confidence ? Math.round(data.category_confidence * 100) : '';
|
|
|
|
result.innerHTML = `
|
|
<div class="space-y-1">
|
|
<p><strong>분류:</strong> ${categoryMap[cat] || cat} ${confidence ? `(${confidence}%)` : ''}</p>
|
|
<p><strong>부서:</strong> ${deptMap[dept] || dept}</p>
|
|
<p><strong>심각도:</strong> ${severity}</p>
|
|
${summary ? `<p><strong>요약:</strong> ${summary}</p>` : ''}
|
|
<button onclick="applyAiClassification('${cat}')"
|
|
class="mt-2 px-3 py-1 bg-purple-600 text-white text-xs rounded hover:bg-purple-700">
|
|
<i class="fas fa-check mr-1"></i>적용
|
|
</button>
|
|
</div>
|
|
`;
|
|
result.classList.remove('hidden');
|
|
}
|
|
|
|
function applyAiClassification(category) {
|
|
const reviewCategory = document.getElementById('reviewCategory');
|
|
if (reviewCategory && category) {
|
|
reviewCategory.value = category;
|
|
}
|
|
if (window.showToast) {
|
|
window.showToast('AI 추천이 적용되었습니다', 'success');
|
|
}
|
|
}
|
|
|
|
// 초기화 (api.js는 HTML에서 로드됨)
|
|
initializeInbox();
|