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:
886
system3-nonconformance/web/static/js/pages/issue-view.js
Normal file
886
system3-nonconformance/web/static/js/pages/issue-view.js
Normal file
@@ -0,0 +1,886 @@
|
||||
/**
|
||||
* issue-view.js — 부적합 조회 페이지 스크립트
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
let issues = [];
|
||||
let projects = []; // 프로젝트 데이터 캐시
|
||||
let currentRange = 'week'; // 기본값: 이번 주
|
||||
|
||||
// 애니메이션 함수들
|
||||
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');
|
||||
|
||||
// 헤더 애니메이션 완료 후 본문 애니메이션
|
||||
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 initializeIssueView() {
|
||||
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_view');
|
||||
|
||||
// 헤더 초기화 후 부드러운 애니메이션 시작
|
||||
setTimeout(() => {
|
||||
animateHeaderAppearance();
|
||||
}, 100);
|
||||
|
||||
// 사용자 역할에 따른 페이지 제목 설정
|
||||
updatePageTitle(user);
|
||||
|
||||
// 페이지 접근 권한 체크 (부적합 조회 페이지)
|
||||
setTimeout(() => {
|
||||
if (!canAccessPage('issues_view')) {
|
||||
alert('부적합 조회 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('인증 실패:', error);
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 프로젝트 로드
|
||||
await loadProjects();
|
||||
|
||||
// 기본 날짜 설정 (이번 주)
|
||||
setDefaultDateRange();
|
||||
|
||||
// 기본값: 이번 주 데이터 로드
|
||||
await loadIssues();
|
||||
setDateRange('week');
|
||||
}
|
||||
|
||||
// showImageModal은 photo-modal.js에서 제공됨
|
||||
|
||||
// 기본 날짜 범위 설정
|
||||
function setDefaultDateRange() {
|
||||
const today = new Date();
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
|
||||
|
||||
// 날짜 입력 필드에 기본값 설정
|
||||
document.getElementById('startDateInput').value = formatDateForInput(weekStart);
|
||||
document.getElementById('endDateInput').value = formatDateForInput(today);
|
||||
}
|
||||
|
||||
// 날짜를 input[type="date"] 형식으로 포맷
|
||||
function formatDateForInput(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 날짜 필터 적용
|
||||
function applyDateFilter() {
|
||||
const startDate = document.getElementById('startDateInput').value;
|
||||
const endDate = document.getElementById('endDateInput').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작날짜와 끝날짜를 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
alert('시작날짜는 끝날짜보다 이전이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 필터 적용
|
||||
filterIssues();
|
||||
}
|
||||
|
||||
// 사용자 역할에 따른 페이지 제목 업데이트
|
||||
function updatePageTitle(user) {
|
||||
const titleElement = document.getElementById('pageTitle');
|
||||
const descriptionElement = document.getElementById('pageDescription');
|
||||
|
||||
if (user.role === 'admin') {
|
||||
titleElement.innerHTML = `
|
||||
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
|
||||
전체 부적합 조회
|
||||
`;
|
||||
descriptionElement.textContent = '모든 사용자가 등록한 부적합 사항을 관리할 수 있습니다';
|
||||
} else {
|
||||
titleElement.innerHTML = `
|
||||
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
|
||||
내 부적합 조회
|
||||
`;
|
||||
descriptionElement.textContent = '내가 등록한 부적합 사항을 확인할 수 있습니다';
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 로드 (API 기반)
|
||||
async function loadProjects() {
|
||||
try {
|
||||
// 모든 프로젝트 로드 (활성/비활성 모두 - 기존 데이터 조회를 위해)
|
||||
projects = await ProjectsAPI.getAll(false);
|
||||
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.job_no} / ${project.project_name}${!project.is_active ? ' (비활성)' : ''}`;
|
||||
projectFilter.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 필터링
|
||||
// 검토 상태 확인 함수
|
||||
function isReviewCompleted(issue) {
|
||||
return issue.status === 'complete' && issue.work_hours && issue.work_hours > 0;
|
||||
}
|
||||
|
||||
// 날짜 필터링 함수
|
||||
function filterByDate(issues, dateFilter) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (dateFilter) {
|
||||
case 'today':
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.report_date);
|
||||
return issueDate >= today;
|
||||
});
|
||||
case 'week':
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay());
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.report_date);
|
||||
return issueDate >= weekStart;
|
||||
});
|
||||
case 'month':
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.report_date);
|
||||
return issueDate >= monthStart;
|
||||
});
|
||||
default:
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 범위별 필터링 함수
|
||||
function filterByDateRange(issues, range) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.created_at);
|
||||
const issueDay = new Date(issueDate.getFullYear(), issueDate.getMonth(), issueDate.getDate());
|
||||
return issueDay.getTime() === today.getTime();
|
||||
});
|
||||
|
||||
case 'week':
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay());
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.created_at);
|
||||
return issueDate >= weekStart && issueDate <= weekEnd;
|
||||
});
|
||||
|
||||
case 'month':
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
monthEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.created_at);
|
||||
return issueDate >= monthStart && issueDate <= monthEnd;
|
||||
});
|
||||
|
||||
default:
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
|
||||
function filterIssues() {
|
||||
// 필터 값 가져오기
|
||||
const selectedProjectId = document.getElementById('projectFilter').value;
|
||||
const reviewStatusFilter = document.getElementById('reviewStatusFilter').value;
|
||||
|
||||
let filteredIssues = [...issues];
|
||||
|
||||
// 프로젝트 필터 적용
|
||||
if (selectedProjectId) {
|
||||
filteredIssues = filteredIssues.filter(issue => {
|
||||
const issueProjectId = issue.project_id || issue.projectId;
|
||||
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// 워크플로우 상태 필터 적용
|
||||
if (reviewStatusFilter) {
|
||||
filteredIssues = filteredIssues.filter(issue => {
|
||||
// 새로운 워크플로우 시스템 사용
|
||||
if (issue.review_status) {
|
||||
return issue.review_status === reviewStatusFilter;
|
||||
}
|
||||
// 기존 데이터 호환성을 위한 폴백
|
||||
else {
|
||||
const isCompleted = isReviewCompleted(issue);
|
||||
if (reviewStatusFilter === 'pending_review') return !isCompleted;
|
||||
if (reviewStatusFilter === 'completed') return isCompleted;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 범위 필터 적용 (입력 필드에서 선택된 범위)
|
||||
const startDateInput = document.getElementById('startDateInput').value;
|
||||
const endDateInput = document.getElementById('endDateInput').value;
|
||||
|
||||
if (startDateInput && endDateInput) {
|
||||
filteredIssues = filteredIssues.filter(issue => {
|
||||
const issueDate = new Date(issue.report_date);
|
||||
const startOfDay = new Date(startDateInput);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(endDateInput);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
return issueDate >= startOfDay && issueDate <= endOfDay;
|
||||
});
|
||||
}
|
||||
|
||||
// 전역 변수에 필터링된 결과 저장
|
||||
window.filteredIssues = filteredIssues;
|
||||
|
||||
displayResults();
|
||||
}
|
||||
|
||||
// 프로젝트 정보 표시용 함수
|
||||
function getProjectInfo(projectId) {
|
||||
if (!projectId) {
|
||||
return '<span class="text-gray-500">프로젝트 미지정</span>';
|
||||
}
|
||||
|
||||
// 전역 projects 배열에서 찾기
|
||||
const project = projects.find(p => p.id == projectId);
|
||||
if (project) {
|
||||
return `${project.job_no} / ${project.project_name}`;
|
||||
}
|
||||
|
||||
return `<span class="text-red-500">프로젝트 ID: ${projectId} (정보 없음)</span>`;
|
||||
}
|
||||
|
||||
// 날짜 범위 설정 및 자동 조회
|
||||
function setDateRange(range) {
|
||||
currentRange = range;
|
||||
|
||||
const today = new Date();
|
||||
let startDate, endDate;
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
startDate = new Date(today);
|
||||
endDate = new Date(today);
|
||||
break;
|
||||
case 'week':
|
||||
startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
|
||||
endDate = new Date(today);
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
|
||||
endDate = new Date(today);
|
||||
break;
|
||||
case 'all':
|
||||
startDate = new Date(2020, 0, 1); // 충분히 과거 날짜
|
||||
endDate = new Date(today);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// 날짜 입력 필드 업데이트
|
||||
document.getElementById('startDateInput').value = formatDateForInput(startDate);
|
||||
document.getElementById('endDateInput').value = formatDateForInput(endDate);
|
||||
|
||||
// 필터 적용
|
||||
filterIssues();
|
||||
}
|
||||
|
||||
// 부적합 사항 로드 (자신이 올린 내용만)
|
||||
async function loadIssues() {
|
||||
const container = document.getElementById('issueResults');
|
||||
container.innerHTML = `
|
||||
<div class="text-gray-500 text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
// 모든 이슈 가져오기
|
||||
const allIssues = await IssuesAPI.getAll();
|
||||
|
||||
// 자신이 올린 이슈만 필터링
|
||||
issues = allIssues
|
||||
.filter(issue => issue.reporter_id === currentUser.id)
|
||||
.sort((a, b) => new Date(b.report_date) - new Date(a.report_date));
|
||||
|
||||
// 결과 표시
|
||||
filterIssues();
|
||||
|
||||
} catch (error) {
|
||||
console.error('부적합 사항 로드 실패:', error);
|
||||
container.innerHTML = `
|
||||
<div class="text-red-500 text-center py-8">
|
||||
<i class="fas fa-exclamation-triangle text-3xl mb-3"></i>
|
||||
<p>데이터를 불러오는데 실패했습니다.</p>
|
||||
<button onclick="loadIssues()" class="mt-3 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 표시 (시간순 나열)
|
||||
function displayResults() {
|
||||
const container = document.getElementById('issueResults');
|
||||
|
||||
// 필터링된 결과 사용 (filterIssues에서 설정됨)
|
||||
const filteredIssues = window.filteredIssues || issues;
|
||||
|
||||
if (filteredIssues.length === 0) {
|
||||
const emptyMessage = currentUser.role === 'admin'
|
||||
? '조건에 맞는 부적합 사항이 없습니다.'
|
||||
: '아직 등록한 부적합 사항이 없습니다.<br><small class="text-sm">부적합 등록 페이지에서 새로운 부적합을 등록해보세요.</small>';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-gray-500 text-center py-12">
|
||||
<i class="fas fa-inbox text-4xl mb-4 text-gray-400"></i>
|
||||
<p class="text-lg mb-2">${emptyMessage}</p>
|
||||
${currentUser.role !== 'admin' ? `
|
||||
<div class="mt-4">
|
||||
<a href="/index.html" class="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
부적합 등록하기
|
||||
</a>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 워크플로우 상태별로 분류 및 정렬
|
||||
const groupedIssues = {
|
||||
pending_review: filteredIssues.filter(issue =>
|
||||
issue.review_status === 'pending_review' || (!issue.review_status && !isReviewCompleted(issue))
|
||||
),
|
||||
in_progress: filteredIssues.filter(issue => issue.review_status === 'in_progress'),
|
||||
completed: filteredIssues.filter(issue =>
|
||||
issue.review_status === 'completed' || (!issue.review_status && isReviewCompleted(issue))
|
||||
),
|
||||
disposed: filteredIssues.filter(issue => issue.review_status === 'disposed')
|
||||
};
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// 각 상태별로 표시
|
||||
const statusConfig = [
|
||||
{ key: 'pending_review', title: '수신함 (검토 대기)', icon: 'fas fa-inbox', color: 'text-orange-700' },
|
||||
{ key: 'in_progress', title: '관리함 (진행 중)', icon: 'fas fa-cog', color: 'text-blue-700' },
|
||||
{ key: 'completed', title: '관리함 (완료됨)', icon: 'fas fa-check-circle', color: 'text-green-700' },
|
||||
{ key: 'disposed', title: '폐기함 (폐기됨)', icon: 'fas fa-trash', color: 'text-gray-700' }
|
||||
];
|
||||
|
||||
statusConfig.forEach((config, index) => {
|
||||
const issues = groupedIssues[config.key];
|
||||
if (issues.length > 0) {
|
||||
const header = document.createElement('div');
|
||||
header.className = index > 0 ? 'mb-4 mt-8' : 'mb-4';
|
||||
header.innerHTML = `
|
||||
<h3 class="text-md font-semibold ${config.color} flex items-center">
|
||||
<i class="${config.icon} mr-2"></i>${config.title} (${issues.length}건)
|
||||
</h3>
|
||||
`;
|
||||
container.appendChild(header);
|
||||
|
||||
issues.forEach(issue => {
|
||||
container.appendChild(createIssueCard(issue, config.key === 'completed'));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 워크플로우 상태 표시 함수
|
||||
function getWorkflowStatusBadge(issue) {
|
||||
const status = issue.review_status || (isReviewCompleted(issue) ? 'completed' : 'pending_review');
|
||||
|
||||
const statusConfig = {
|
||||
'pending_review': { text: '검토 대기', class: 'bg-orange-100 text-orange-700', icon: 'fas fa-inbox' },
|
||||
'in_progress': { text: '진행 중', class: 'bg-blue-100 text-blue-700', icon: 'fas fa-cog' },
|
||||
'completed': { text: '완료됨', class: 'bg-green-100 text-green-700', icon: 'fas fa-check-circle' },
|
||||
'disposed': { text: '폐기됨', class: 'bg-gray-100 text-gray-700', icon: 'fas fa-trash' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig['pending_review'];
|
||||
return `<span class="px-2 py-1 rounded-full text-xs font-medium ${config.class}">
|
||||
<i class="${config.icon} mr-1"></i>${config.text}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// 부적합 사항 카드 생성 함수 (조회용)
|
||||
function createIssueCard(issue, isCompleted) {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
|
||||
const categoryColors = {
|
||||
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
design_error: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
incoming_defect: 'bg-red-100 text-red-700 border-red-300',
|
||||
inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300'
|
||||
};
|
||||
|
||||
const div = document.createElement('div');
|
||||
// 검토 완료 상태에 따른 스타일링
|
||||
const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4';
|
||||
const statusClasses = isCompleted
|
||||
? 'bg-gray-100 opacity-75'
|
||||
: 'bg-gray-50 hover:bg-gray-100';
|
||||
const borderColor = categoryColors[issue.category]?.split(' ')[2] || 'border-gray-300';
|
||||
div.className = `${baseClasses} ${statusClasses} ${borderColor}`;
|
||||
|
||||
const dateStr = DateUtils.formatKST(issue.report_date, true);
|
||||
const relativeTime = DateUtils.getRelativeTime(issue.report_date);
|
||||
const projectInfo = getProjectInfo(issue.project_id || issue.projectId);
|
||||
|
||||
// 수정/삭제 권한 확인 (본인이 등록한 부적합만)
|
||||
const canEdit = issue.reporter_id === currentUser.id;
|
||||
const canDelete = issue.reporter_id === currentUser.id || currentUser.role === 'admin';
|
||||
|
||||
div.innerHTML = `
|
||||
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
|
||||
<div class="flex justify-between items-start p-2 pb-0">
|
||||
<div class="flex items-center gap-2">
|
||||
${getWorkflowStatusBadge(issue)}
|
||||
</div>
|
||||
<div class="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
|
||||
<i class="fas fa-folder-open mr-1"></i>${projectInfo}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기존 내용 -->
|
||||
<div class="flex gap-3 p-3 pt-1">
|
||||
<!-- 사진들 -->
|
||||
<div class="flex gap-1 flex-shrink-0 flex-wrap max-w-md">
|
||||
${(() => {
|
||||
const photos = [
|
||||
issue.photo_path,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
].filter(p => p);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return `
|
||||
<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400"></i>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return photos.map(path => `
|
||||
<img src="${path}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${path}')">
|
||||
`).join('');
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium ${categoryColors[issue.category] || 'bg-gray-100 text-gray-700'}">
|
||||
${categoryNames[issue.category] || issue.category}
|
||||
</span>
|
||||
${issue.work_hours ?
|
||||
`<span class="text-sm text-green-600 font-medium">
|
||||
<i class="fas fa-clock mr-1"></i>${issue.work_hours}시간
|
||||
</span>` :
|
||||
'<span class="text-sm text-gray-400">시간 미입력</span>'
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="text-gray-800 mb-2 line-clamp-2">${issue.description}</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span>
|
||||
<span><i class="fas fa-calendar mr-1"></i>${dateStr}</span>
|
||||
<span class="text-xs text-gray-400">${relativeTime}</span>
|
||||
</div>
|
||||
|
||||
<!-- 수정/삭제 버튼 -->
|
||||
${(canEdit || canDelete) ? `
|
||||
<div class="flex gap-2">
|
||||
${canEdit ? `
|
||||
<button onclick='showEditModal(${JSON.stringify(issue).replace(/'/g, "'")})' class="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-edit mr-1"></i>수정
|
||||
</button>
|
||||
` : ''}
|
||||
${canDelete ? `
|
||||
<button onclick="confirmDelete(${issue.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600 transition-colors">
|
||||
<i class="fas fa-trash mr-1"></i>삭제
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
// 관리 버튼 클릭 처리
|
||||
function handleAdminClick() {
|
||||
if (currentUser.role === 'admin') {
|
||||
// 관리자: 사용자 관리 페이지로 이동
|
||||
window.location.href = 'admin.html';
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀번호 변경 모달 표시
|
||||
function showPasswordChangeModal() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">비밀번호 변경</h3>
|
||||
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="passwordChangeForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
|
||||
<input type="password" id="currentPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
|
||||
<input type="password" id="newPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required minlength="6">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
|
||||
<input type="password" id="confirmPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||
변경
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// 폼 제출 이벤트 처리
|
||||
document.getElementById('passwordChangeForm').addEventListener('submit', handlePasswordChange);
|
||||
}
|
||||
|
||||
// 비밀번호 변경 처리
|
||||
async function handlePasswordChange(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
// 새 비밀번호 확인
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 비밀번호 확인 (localStorage 기반)
|
||||
let users = JSON.parse(localStorage.getItem('work-report-users') || '[]');
|
||||
|
||||
// 기본 사용자가 없으면 생성
|
||||
if (users.length === 0) {
|
||||
users = [
|
||||
{
|
||||
username: 'hyungi',
|
||||
full_name: '관리자',
|
||||
password: 'djg3-jj34-X3Q3',
|
||||
role: 'admin'
|
||||
}
|
||||
];
|
||||
localStorage.setItem('work-report-users', JSON.stringify(users));
|
||||
}
|
||||
|
||||
let user = users.find(u => u.username === currentUser.username);
|
||||
|
||||
// 사용자가 없으면 기본값으로 생성
|
||||
if (!user) {
|
||||
const username = currentUser.username;
|
||||
user = {
|
||||
username: username,
|
||||
full_name: username === 'hyungi' ? '관리자' : username,
|
||||
password: 'djg3-jj34-X3Q3',
|
||||
role: username === 'hyungi' ? 'admin' : 'user'
|
||||
};
|
||||
users.push(user);
|
||||
localStorage.setItem('work-report-users', JSON.stringify(users));
|
||||
}
|
||||
|
||||
if (user.password !== currentPassword) {
|
||||
alert('현재 비밀번호가 올바르지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 비밀번호 변경
|
||||
user.password = newPassword;
|
||||
localStorage.setItem('work-report-users', JSON.stringify(users));
|
||||
|
||||
// 현재 사용자 정보도 업데이트
|
||||
currentUser.password = newPassword;
|
||||
localStorage.setItem('currentUser', JSON.stringify(currentUser));
|
||||
|
||||
alert('비밀번호가 성공적으로 변경되었습니다.');
|
||||
document.querySelector('.fixed').remove(); // 모달 닫기
|
||||
|
||||
} catch (error) {
|
||||
alert('비밀번호 변경에 실패했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃 함수
|
||||
function logout() {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// 수정 모달 표시
|
||||
function showEditModal(issue) {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">부적합 수정</h3>
|
||||
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="editIssueForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
|
||||
<select id="editCategory" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
|
||||
<option value="design_error" ${issue.category === 'design_error' ? 'selected' : ''}>설계미스</option>
|
||||
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
|
||||
<option value="inspection_miss" ${issue.category === 'inspection_miss' ? 'selected' : ''}>검사미스</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
|
||||
<select id="editProject" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||
${projects.map(p => `
|
||||
<option value="${p.id}" ${p.id === issue.project_id ? 'selected' : ''}>
|
||||
${p.job_no} / ${p.project_name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">내용</label>
|
||||
<textarea id="editDescription" class="w-full px-3 py-2 border border-gray-300 rounded-lg" rows="4" required>${issue.description || ''}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// 폼 제출 이벤트 처리
|
||||
document.getElementById('editIssueForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const updateData = {
|
||||
category: document.getElementById('editCategory').value,
|
||||
description: document.getElementById('editDescription').value,
|
||||
project_id: parseInt(document.getElementById('editProject').value)
|
||||
};
|
||||
|
||||
try {
|
||||
await IssuesAPI.update(issue.id, updateData);
|
||||
alert('수정되었습니다.');
|
||||
modal.remove();
|
||||
// 목록 새로고침
|
||||
await loadIssues();
|
||||
} catch (error) {
|
||||
console.error('수정 실패:', error);
|
||||
alert('수정에 실패했습니다: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
function confirmDelete(issueId) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
||||
<div class="text-center mb-4">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">부적합 삭제</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
이 부적합 사항을 삭제하시겠습니까?<br>
|
||||
삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button onclick="this.closest('.fixed').remove()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button onclick="handleDelete(${issueId})"
|
||||
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 삭제 처리
|
||||
async function handleDelete(issueId) {
|
||||
try {
|
||||
await IssuesAPI.delete(issueId);
|
||||
alert('삭제되었습니다.');
|
||||
|
||||
// 모달 닫기
|
||||
const modal = document.querySelector('.fixed');
|
||||
if (modal) modal.remove();
|
||||
|
||||
// 목록 새로고침
|
||||
await loadIssues();
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
alert('삭제에 실패했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// API 스크립트 동적 로딩
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/api.js?v=20260213';
|
||||
script.onload = function() {
|
||||
console.log('API 스크립트 로드 완료 (issue-view.html)');
|
||||
// API 로드 후 초기화 시작
|
||||
initializeIssueView();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error('API 스크립트 로드 실패');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
332
system3-nonconformance/web/static/js/pages/issues-archive.js
Normal file
332
system3-nonconformance/web/static/js/pages/issues-archive.js
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* issues-archive.js — 폐기함 페이지 스크립트
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
let issues = [];
|
||||
let projects = [];
|
||||
let filteredIssues = [];
|
||||
|
||||
// API 로드 후 초기화 함수
|
||||
async function initializeArchive() {
|
||||
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_archive');
|
||||
|
||||
// 페이지 접근 권한 체크
|
||||
setTimeout(() => {
|
||||
if (!canAccessPage('issues_archive')) {
|
||||
alert('폐기함 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 데이터 로드
|
||||
await loadProjects();
|
||||
await loadArchivedIssues();
|
||||
|
||||
} catch (error) {
|
||||
console.error('인증 실패:', error);
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 로드
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 보관된 부적합 로드
|
||||
async function loadArchivedIssues() {
|
||||
try {
|
||||
let endpoint = '/api/issues/';
|
||||
|
||||
// 관리자인 경우 전체 부적합 조회 API 사용
|
||||
if (currentUser.role === 'admin') {
|
||||
endpoint = '/api/issues/admin/all';
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const allIssues = await response.json();
|
||||
// 폐기된 부적합만 필터링 (폐기함 전용)
|
||||
issues = allIssues.filter(issue =>
|
||||
issue.review_status === 'disposed'
|
||||
);
|
||||
|
||||
filterIssues();
|
||||
updateStatistics();
|
||||
renderCharts();
|
||||
} else {
|
||||
throw new Error('부적합 목록을 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('부적합 로드 실패:', error);
|
||||
alert('부적합 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 필터링 및 표시
|
||||
function filterIssues() {
|
||||
const projectFilter = document.getElementById('projectFilter').value;
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
const periodFilter = document.getElementById('periodFilter').value;
|
||||
const categoryFilter = document.getElementById('categoryFilter').value;
|
||||
const searchInput = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
filteredIssues = issues.filter(issue => {
|
||||
if (projectFilter && issue.project_id != projectFilter) return false;
|
||||
if (statusFilter && issue.status !== statusFilter) return false;
|
||||
if (categoryFilter && issue.category !== categoryFilter) return false;
|
||||
|
||||
// 기간 필터
|
||||
if (periodFilter) {
|
||||
const issueDate = new Date(issue.updated_at || issue.created_at);
|
||||
const now = new Date();
|
||||
|
||||
switch (periodFilter) {
|
||||
case 'week':
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
if (issueDate < weekAgo) return false;
|
||||
break;
|
||||
case 'month':
|
||||
const monthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
if (issueDate < monthAgo) return false;
|
||||
break;
|
||||
case 'quarter':
|
||||
const quarterAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
if (issueDate < quarterAgo) return false;
|
||||
break;
|
||||
case 'year':
|
||||
const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
if (issueDate < yearAgo) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
const searchText = `${issue.description} ${issue.reporter?.username || ''}`.toLowerCase();
|
||||
if (!searchText.includes(searchInput)) 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 'completed':
|
||||
return new Date(b.disposed_at || b.report_date) - new Date(a.disposed_at || a.report_date);
|
||||
case 'category':
|
||||
return (a.category || '').localeCompare(b.category || '');
|
||||
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 completedDate = issue.disposed_at ? new Date(issue.disposed_at).toLocaleDateString('ko-KR') : 'Invalid Date';
|
||||
const statusText = '폐기';
|
||||
const cardClass = 'archived-card';
|
||||
|
||||
return `
|
||||
<div class="issue-card p-6 ${cardClass} cursor-pointer"
|
||||
onclick="viewArchivedIssue(${issue.id})">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="badge badge-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</span>
|
||||
${project ? `<span class="text-sm text-gray-500">${project.project_name}</span>` : ''}
|
||||
<span class="text-sm text-gray-400">${completedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">${issue.description}</h3>
|
||||
|
||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span>
|
||||
${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''}
|
||||
<span><i class="fas fa-clock mr-1"></i>${statusText}: ${completedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<i class="fas fa-${getStatusIcon(issue.status)} text-2xl ${getStatusColor(issue.status)}"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStatistics() {
|
||||
const completed = issues.filter(issue => issue.status === 'completed').length;
|
||||
const archived = issues.filter(issue => issue.status === 'archived').length;
|
||||
const cancelled = issues.filter(issue => issue.status === 'cancelled').length;
|
||||
|
||||
const thisMonth = issues.filter(issue => {
|
||||
const issueDate = new Date(issue.updated_at || issue.created_at);
|
||||
const now = new Date();
|
||||
return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
|
||||
document.getElementById('completedCount').textContent = completed;
|
||||
document.getElementById('archivedCount').textContent = archived;
|
||||
document.getElementById('cancelledCount').textContent = cancelled;
|
||||
document.getElementById('thisMonthCount').textContent = thisMonth;
|
||||
}
|
||||
|
||||
// 차트 렌더링 (간단한 텍스트 기반)
|
||||
function renderCharts() {
|
||||
renderMonthlyChart();
|
||||
renderCategoryChart();
|
||||
}
|
||||
|
||||
function renderMonthlyChart() {
|
||||
const canvas = document.getElementById('monthlyChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '16px Inter';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('월별 완료 현황 차트', canvas.width / 2, canvas.height / 2);
|
||||
ctx.font = '12px Inter';
|
||||
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
|
||||
}
|
||||
|
||||
function renderCategoryChart() {
|
||||
const canvas = document.getElementById('categoryChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '16px Inter';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('카테고리별 분포 차트', canvas.width / 2, canvas.height / 2);
|
||||
ctx.font = '12px Inter';
|
||||
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
|
||||
}
|
||||
|
||||
// 기타 함수들
|
||||
function generateReport() {
|
||||
alert('통계 보고서를 생성합니다.');
|
||||
}
|
||||
|
||||
function cleanupArchive() {
|
||||
if (confirm('오래된 보관 데이터를 정리하시겠습니까?')) {
|
||||
alert('데이터 정리가 완료되었습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function viewArchivedIssue(issueId) {
|
||||
window.location.href = `/issue-view.html#detail-${issueId}`;
|
||||
}
|
||||
|
||||
// 유틸리티 함수들
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 전용 유틸리티 (shared에 없는 것들)
|
||||
function getStatusIcon(status) {
|
||||
const iconMap = {
|
||||
'completed': 'check-circle',
|
||||
'archived': 'archive',
|
||||
'cancelled': 'times-circle'
|
||||
};
|
||||
return iconMap[status] || 'archive';
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colorMap = {
|
||||
'completed': 'text-green-500',
|
||||
'archived': 'text-gray-500',
|
||||
'cancelled': 'text-red-500'
|
||||
};
|
||||
return colorMap[status] || 'text-gray-500';
|
||||
}
|
||||
|
||||
// API 스크립트 동적 로딩
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/api.js?v=20260213';
|
||||
script.onload = function() {
|
||||
console.log('API 스크립트 로드 완료 (issues-archive.html)');
|
||||
initializeArchive();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error('API 스크립트 로드 실패');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
1793
system3-nonconformance/web/static/js/pages/issues-dashboard.js
Normal file
1793
system3-nonconformance/web/static/js/pages/issues-dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
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);
|
||||
2165
system3-nonconformance/web/static/js/pages/issues-management.js
Normal file
2165
system3-nonconformance/web/static/js/pages/issues-management.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user