🎨 UI Components: - 3개 워크플로우 모달 추가: * 폐기 모달: 사유 선택 (중복/무효신고/해당없음/스팸/직접입력) * 검토 모달: 프로젝트/카테고리/설명 수정 + 원본 정보 표시 * 상태 모달: 진행중/완료 선택 + 처리 메모 - 부적합 카드 UI 개선: * 워크플로우 액션 버튼 (폐기/검토/승인) * 읽음/안읽음 상태 표시 * 사진 첨부 여부 표시 * 클릭 가능한 제목 (상세보기) 🔌 API Integration: - 실제 백엔드 API 완전 연동: * GET /api/inbox/ - 수신함 목록 (프로젝트 필터링) * GET /api/inbox/statistics - 실시간 통계 * POST /api/inbox/{id}/dispose - 폐기 처리 * POST /api/inbox/{id}/review - 검토/수정 * POST /api/inbox/{id}/status - 상태 변경 - 에러 처리 및 사용자 피드백: * API 오류 시 적절한 메시지 표시 * 성공 시 결과 안내 및 목록 자동 새로고침 * 입력 검증 (필수값, 사용자 정의 사유 등) 🎯 Workflow Logic: - 폐기 처리: * 5가지 사유 선택 (기본값: 중복) * 사용자 정의 사유 입력 검증 * 폐기 후 폐기함으로 이동 - 검토/수정: * 원본 정보 보존 및 표시 * 프로젝트/카테고리/설명 수정 가능 * 수정 이력 자동 추적 - 상태 결정: * 진행중/완료 선택 * 처리 메모 추가 가능 * 관리함으로 자동 이동 📊 Real-time Features: - 실시간 통계 업데이트 - 읽음 상태 로컬 저장 (inbox_read_status) - 프로젝트별 필터링 - 자동 목록 새로고침 🎨 UX Improvements: - 모달 기반 워크플로우 (직관적) - 원본 정보 표시 (수정 전후 비교) - 적절한 로딩 상태 표시 - 사용자 친화적 에러 메시지 - 액션 버튼 색상 구분 (빨강/파랑/초록) Result: ✅ 수신함 워크플로우 프론트엔드 100% 완성 ✅ 백엔드 API와 완벽 연동 ✅ 실시간 데이터 동기화 ✅ 사용자 친화적 UI/UX ✅ 모든 워크플로우 액션 구현
978 lines
44 KiB
HTML
978 lines
44 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>수신함 - 작업보고서</title>
|
|
|
|
<!-- Tailwind CSS -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
<!-- Font Awesome -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
|
|
<!-- 모바일 캘린더 스타일 -->
|
|
<link rel="stylesheet" href="/static/css/mobile-calendar.css">
|
|
|
|
<!-- Custom Styles -->
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
|
|
|
body {
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 9999;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.loading-overlay.active {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
|
|
.issue-card {
|
|
transition: all 0.2s ease;
|
|
border-left: 4px solid transparent;
|
|
}
|
|
|
|
.issue-card.unread {
|
|
border-left-color: #3b82f6;
|
|
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
|
|
}
|
|
|
|
.issue-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.priority-high { border-left-color: #ef4444; }
|
|
.priority-medium { border-left-color: #f59e0b; }
|
|
.priority-low { border-left-color: #10b981; }
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.badge-new { background: #dbeafe; color: #1e40af; }
|
|
.badge-processing { background: #fef3c7; color: #92400e; }
|
|
.badge-completed { background: #d1fae5; color: #065f46; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-50 min-h-screen">
|
|
<!-- 로딩 오버레이 -->
|
|
<div id="loadingOverlay" class="loading-overlay">
|
|
<div class="text-center">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p class="text-gray-600">데이터를 불러오는 중...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
|
|
|
<!-- Main Content -->
|
|
<main class="container mx-auto px-4 py-8" style="padding-top: 120px;">
|
|
<!-- 페이지 헤더 -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
|
<i class="fas fa-inbox text-blue-500 mr-3"></i>
|
|
수신함
|
|
</h1>
|
|
<p class="text-gray-600 mt-1">새로 등록된 부적합 사항을 확인하고 처리하세요</p>
|
|
</div>
|
|
<div class="flex items-center space-x-3">
|
|
<button onclick="markAllAsRead()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
|
<i class="fas fa-check-double mr-2"></i>
|
|
모두 읽음 처리
|
|
</button>
|
|
<button onclick="refreshInbox()" class="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors">
|
|
<i class="fas fa-sync-alt mr-2"></i>
|
|
새로고침
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 통계 카드 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div class="bg-blue-50 p-4 rounded-lg">
|
|
<div class="flex items-center">
|
|
<i class="fas fa-envelope text-blue-500 text-xl mr-3"></i>
|
|
<div>
|
|
<p class="text-sm text-blue-600">새 부적합</p>
|
|
<p class="text-2xl font-bold text-blue-700" id="newIssuesCount">0</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-yellow-50 p-4 rounded-lg">
|
|
<div class="flex items-center">
|
|
<i class="fas fa-clock text-yellow-500 text-xl mr-3"></i>
|
|
<div>
|
|
<p class="text-sm text-yellow-600">처리 대기</p>
|
|
<p class="text-2xl font-bold text-yellow-700" id="pendingIssuesCount">0</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-green-50 p-4 rounded-lg">
|
|
<div class="flex items-center">
|
|
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
|
|
<div>
|
|
<p class="text-sm text-green-600">오늘 처리</p>
|
|
<p class="text-2xl font-bold text-green-700" id="todayProcessedCount">0</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-purple-50 p-4 rounded-lg">
|
|
<div class="flex items-center">
|
|
<i class="fas fa-chart-line text-purple-500 text-xl mr-3"></i>
|
|
<div>
|
|
<p class="text-sm text-purple-600">전체</p>
|
|
<p class="text-2xl font-bold text-purple-700" id="totalIssuesCount">0</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 필터 및 검색 -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<!-- 프로젝트 필터 -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">📁 프로젝트</label>
|
|
<select id="projectFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" onchange="filterIssues()">
|
|
<option value="">전체 프로젝트</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 상태 필터 -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">📋 상태</label>
|
|
<select id="statusFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" onchange="filterIssues()">
|
|
<option value="">전체 상태</option>
|
|
<option value="new">새 부적합</option>
|
|
<option value="processing">처리 중</option>
|
|
<option value="pending">대기 중</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 읽음 상태 필터 -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">👁️ 읽음 상태</label>
|
|
<select id="readStatusFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" onchange="filterIssues()">
|
|
<option value="">전체</option>
|
|
<option value="unread">읽지 않음</option>
|
|
<option value="read">읽음</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 검색 -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">🔍 검색</label>
|
|
<input type="text" id="searchInput" placeholder="설명 또는 등록자 검색..."
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
onkeyup="filterIssues()">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 부적합 목록 -->
|
|
<div class="bg-white rounded-xl shadow-sm">
|
|
<div class="p-6 border-b border-gray-200">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold text-gray-800">부적합 목록</h2>
|
|
<div class="flex items-center space-x-2">
|
|
<span class="text-sm text-gray-500">정렬:</span>
|
|
<select id="sortOrder" class="text-sm border border-gray-300 rounded px-2 py-1" onchange="sortIssues()">
|
|
<option value="newest">최신순</option>
|
|
<option value="oldest">오래된순</option>
|
|
<option value="priority">우선순위</option>
|
|
<option value="unread">읽지 않은 순</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="issuesList" class="divide-y divide-gray-200">
|
|
<!-- 부적합 목록이 여기에 동적으로 생성됩니다 -->
|
|
</div>
|
|
|
|
<!-- 빈 상태 -->
|
|
<div id="emptyState" class="hidden p-12 text-center">
|
|
<i class="fas fa-inbox text-6xl text-gray-300 mb-4"></i>
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">수신함이 비어있습니다</h3>
|
|
<p class="text-gray-500">새로운 부적합이 등록되면 여기에 표시됩니다.</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- 폐기 모달 -->
|
|
<div id="disposeModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
|
|
<div class="flex items-center justify-center min-h-screen p-4">
|
|
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900">부적합 폐기</h3>
|
|
<button onclick="closeDisposeModal()" class="text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">폐기 사유</label>
|
|
<select id="disposalReason" class="w-full px-3 py-2 border border-gray-300 rounded-lg" onchange="toggleCustomReason()">
|
|
<option value="duplicate">중복 (기본)</option>
|
|
<option value="invalid_report">잘못된 신고</option>
|
|
<option value="not_applicable">해당 없음</option>
|
|
<option value="spam">스팸/오류</option>
|
|
<option value="custom">직접 입력</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div id="customReasonDiv" class="hidden">
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">사용자 정의 사유</label>
|
|
<textarea id="customReason" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
placeholder="폐기 사유를 입력하세요..."></textarea>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3">
|
|
<button onclick="closeDisposeModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
|
취소
|
|
</button>
|
|
<button onclick="confirmDispose()" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
|
|
폐기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 검토/수정 모달 -->
|
|
<div id="reviewModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
|
|
<div class="flex items-center justify-center min-h-screen p-4">
|
|
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900">부적합 검토 및 수정</h3>
|
|
<button onclick="closeReviewModal()" class="text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<!-- 원본 정보 표시 -->
|
|
<div class="bg-gray-50 p-4 rounded-lg">
|
|
<h4 class="font-medium text-gray-700 mb-2">원본 정보</h4>
|
|
<div id="originalInfo" class="text-sm text-gray-600">
|
|
<!-- 원본 정보가 여기에 표시됩니다 -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 수정 폼 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">프로젝트</label>
|
|
<select id="reviewProjectId" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
|
<option value="">프로젝트 선택</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
|
|
<select id="reviewCategory" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
|
<option value="material_missing">자재 누락</option>
|
|
<option value="design_error">설계 오류</option>
|
|
<option value="incoming_defect">반입 불량</option>
|
|
<option value="inspection_miss">검사 누락</option>
|
|
<option value="etc">기타</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
|
<textarea id="reviewDescription" rows="4" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
placeholder="부적합 설명을 입력하세요..."></textarea>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3">
|
|
<button onclick="closeReviewModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
|
취소
|
|
</button>
|
|
<button onclick="saveReview()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
|
검토 완료
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 상태 결정 모달 -->
|
|
<div id="statusModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
|
|
<div class="flex items-center justify-center min-h-screen p-4">
|
|
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900">최종 상태 결정</h3>
|
|
<button onclick="closeStatusModal()" class="text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">상태 선택</label>
|
|
<div class="space-y-2">
|
|
<label class="flex items-center">
|
|
<input type="radio" name="finalStatus" value="in_progress" class="mr-2">
|
|
<span class="text-sm">🔄 진행 중 (관리함으로 이동)</span>
|
|
</label>
|
|
<label class="flex items-center">
|
|
<input type="radio" name="finalStatus" value="completed" class="mr-2">
|
|
<span class="text-sm">✅ 완료됨 (관리함으로 이동)</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">처리 메모 (선택사항)</label>
|
|
<textarea id="statusNotes" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
placeholder="처리 내용이나 특이사항을 입력하세요..."></textarea>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3">
|
|
<button onclick="closeStatusModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
|
취소
|
|
</button>
|
|
<button onclick="confirmStatus()" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
|
|
상태 변경
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scripts -->
|
|
<script src="/static/js/date-utils.js?v=20250917"></script>
|
|
<script src="/static/js/core/permissions.js?v=20251025"></script>
|
|
<script src="/static/js/components/common-header.js?v=20251025"></script>
|
|
<script src="/static/js/core/page-manager.js?v=20251025"></script>
|
|
<script src="/static/js/components/mobile-calendar.js?v=20251025"></script>
|
|
<script>
|
|
let currentUser = null;
|
|
let issues = [];
|
|
let projects = [];
|
|
let filteredIssues = [];
|
|
let readStatus = new Set(); // 읽은 부적합 ID 저장
|
|
|
|
// API 로드 후 초기화 함수
|
|
async function initializeInbox() {
|
|
const token = localStorage.getItem('access_token');
|
|
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(() => {
|
|
if (!canAccessPage('issues_inbox')) {
|
|
alert('수신함 페이지에 접근할 권한이 없습니다.');
|
|
window.location.href = '/index.html';
|
|
return;
|
|
}
|
|
}, 500);
|
|
|
|
// 데이터 로드
|
|
await loadProjects();
|
|
await loadIssues();
|
|
updateStatistics();
|
|
|
|
} catch (error) {
|
|
console.error('인증 실패:', error);
|
|
localStorage.removeItem('access_token');
|
|
localStorage.removeItem('currentUser');
|
|
window.location.href = '/index.html';
|
|
}
|
|
}
|
|
|
|
// 프로젝트 로드
|
|
async function loadProjects() {
|
|
try {
|
|
const response = await fetch('/api/projects/', {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'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.name;
|
|
projectFilter.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// 수신함 부적합 목록 로드 (실제 API 연동)
|
|
async function loadIssues() {
|
|
showLoading(true);
|
|
try {
|
|
const projectId = document.getElementById('projectFilter').value;
|
|
let url = '/api/inbox/';
|
|
|
|
// 프로젝트 필터 적용
|
|
if (projectId) {
|
|
url += `?project_id=${projectId}`;
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
issues = await response.json();
|
|
|
|
// 읽음 상태 로드 (localStorage에서)
|
|
const savedReadStatus = localStorage.getItem('inbox_read_status');
|
|
if (savedReadStatus) {
|
|
readStatus = new Set(JSON.parse(savedReadStatus));
|
|
}
|
|
|
|
filterIssues();
|
|
await loadStatistics();
|
|
} else {
|
|
throw new Error('수신함 목록을 불러올 수 없습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('수신함 로드 실패:', error);
|
|
showError('수신함 목록을 불러오는데 실패했습니다.');
|
|
} finally {
|
|
showLoading(false);
|
|
}
|
|
}
|
|
|
|
// 부적합 필터링
|
|
function filterIssues() {
|
|
const projectFilter = document.getElementById('projectFilter').value;
|
|
const statusFilter = document.getElementById('statusFilter').value;
|
|
const readStatusFilter = document.getElementById('readStatusFilter').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 (readStatusFilter === 'read' && !readStatus.has(issue.id)) return false;
|
|
if (readStatusFilter === 'unread' && readStatus.has(issue.id)) return false;
|
|
|
|
// 검색 필터
|
|
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.created_at) - new Date(a.created_at);
|
|
case 'oldest':
|
|
return new Date(a.created_at) - new Date(b.created_at);
|
|
case 'priority':
|
|
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
|
return (priorityOrder[b.priority] || 1) - (priorityOrder[a.priority] || 1);
|
|
case 'unread':
|
|
const aRead = readStatus.has(a.id) ? 1 : 0;
|
|
const bRead = readStatus.has(b.id) ? 1 : 0;
|
|
return aRead - bRead;
|
|
default:
|
|
return new Date(b.created_at) - new Date(a.created_at);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 부적합 목록 표시
|
|
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 isUnread = !readStatus.has(issue.id);
|
|
const project = projects.find(p => p.id === issue.project_id);
|
|
const createdDate = new Date(issue.created_at).toLocaleDateString('ko-KR');
|
|
const timeAgo = getTimeAgo(new Date(issue.created_at));
|
|
|
|
return `
|
|
<div class="issue-card p-6 hover:bg-gray-50 ${isUnread ? 'unread' : ''}"
|
|
data-issue-id="${issue.id}">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1">
|
|
<div class="flex items-center space-x-3 mb-2">
|
|
${isUnread ? '<div class="w-2 h-2 bg-blue-500 rounded-full"></div>' : '<div class="w-2 h-2"></div>'}
|
|
<span class="badge badge-new">검토 대기</span>
|
|
${project ? `<span class="text-sm text-gray-500">${project.name}</span>` : ''}
|
|
<span class="text-sm text-gray-400">${timeAgo}</span>
|
|
</div>
|
|
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2 cursor-pointer" onclick="viewIssueDetail(${issue.id})">${issue.description}</h3>
|
|
|
|
<div class="flex items-center text-sm text-gray-500 space-x-4 mb-3">
|
|
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span>
|
|
<span><i class="fas fa-calendar mr-1"></i>${createdDate}</span>
|
|
${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''}
|
|
${issue.photo_path ? '<span><i class="fas fa-camera mr-1"></i>사진 첨부</span>' : ''}
|
|
</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 class="flex items-center space-x-2 ml-4">
|
|
<button onclick="markAsRead(${issue.id})"
|
|
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
|
|
title="${isUnread ? '읽음 처리' : '읽음'}">
|
|
<i class="fas fa-${isUnread ? 'envelope' : 'envelope-open'}"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// 수신함 통계 로드 (실제 API 연동)
|
|
async function loadStatistics() {
|
|
try {
|
|
const response = await fetch('/api/inbox/statistics', {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const stats = await response.json();
|
|
document.getElementById('newIssuesCount').textContent = stats.pending_review || 0;
|
|
document.getElementById('pendingIssuesCount').textContent = stats.total_in_management || 0;
|
|
document.getElementById('todayProcessedCount').textContent = stats.today_processed || 0;
|
|
document.getElementById('totalIssuesCount').textContent = stats.total_issues || 0;
|
|
} else {
|
|
console.error('통계 로드 실패');
|
|
}
|
|
} catch (error) {
|
|
console.error('통계 로드 오류:', error);
|
|
}
|
|
}
|
|
|
|
// 읽음 처리
|
|
function markAsRead(issueId) {
|
|
readStatus.add(issueId);
|
|
localStorage.setItem('inbox_read_status', JSON.stringify([...readStatus]));
|
|
displayIssues();
|
|
}
|
|
|
|
// 모두 읽음 처리
|
|
function markAllAsRead() {
|
|
filteredIssues.forEach(issue => readStatus.add(issue.id));
|
|
localStorage.setItem('inbox_read_status', JSON.stringify([...readStatus]));
|
|
displayIssues();
|
|
}
|
|
|
|
// 새로고침
|
|
function refreshInbox() {
|
|
loadIssues();
|
|
}
|
|
|
|
// 부적합 상세 보기
|
|
function viewIssueDetail(issueId) {
|
|
markAsRead(issueId);
|
|
window.location.href = `/issue-view.html#detail-${issueId}`;
|
|
}
|
|
|
|
// ===== 워크플로우 모달 관련 함수들 =====
|
|
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('disposeModal').classList.remove('hidden');
|
|
}
|
|
|
|
// 폐기 모달 닫기
|
|
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');
|
|
}
|
|
}
|
|
|
|
// 폐기 확인
|
|
async function confirmDispose() {
|
|
if (!currentIssueId) return;
|
|
|
|
const disposalReason = document.getElementById('disposalReason').value;
|
|
const customReason = document.getElementById('customReason').value;
|
|
|
|
// 사용자 정의 사유 검증
|
|
if (disposalReason === 'custom' && !customReason.trim()) {
|
|
alert('사용자 정의 폐기 사유를 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/inbox/${currentIssueId}/dispose`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
disposal_reason: disposalReason,
|
|
custom_disposal_reason: disposalReason === 'custom' ? customReason : null
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
alert(`부적합이 성공적으로 폐기되었습니다.\n사유: ${getDisposalReasonText(disposalReason)}`);
|
|
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.name : '미지정'}</div>
|
|
<div><strong>카테고리:</strong> ${getCategoryText(issue.category)}</div>
|
|
<div><strong>설명:</strong> ${issue.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.name;
|
|
if (project.id === issue.project_id) {
|
|
option.selected = true;
|
|
}
|
|
reviewProjectSelect.appendChild(option);
|
|
});
|
|
|
|
// 현재 값들로 폼 초기화
|
|
document.getElementById('reviewCategory').value = issue.category;
|
|
document.getElementById('reviewDescription').value = issue.description;
|
|
|
|
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 description = document.getElementById('reviewDescription').value.trim();
|
|
|
|
if (!description) {
|
|
alert('설명을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/inbox/${currentIssueId}/review`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
project_id: projectId ? parseInt(projectId) : null,
|
|
category: category,
|
|
description: description
|
|
})
|
|
});
|
|
|
|
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('statusNotes').value = '';
|
|
|
|
document.getElementById('statusModal').classList.remove('hidden');
|
|
}
|
|
|
|
// 상태 모달 닫기
|
|
function closeStatusModal() {
|
|
currentIssueId = null;
|
|
document.getElementById('statusModal').classList.add('hidden');
|
|
}
|
|
|
|
// 상태 변경 확인
|
|
async function confirmStatus() {
|
|
if (!currentIssueId) return;
|
|
|
|
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
|
|
if (!selectedStatus) {
|
|
alert('상태를 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
const reviewStatus = selectedStatus.value;
|
|
const notes = document.getElementById('statusNotes').value.trim();
|
|
|
|
try {
|
|
const response = await fetch(`/api/inbox/${currentIssueId}/status`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
review_status: reviewStatus,
|
|
notes: notes || null
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
alert(`상태가 성공적으로 변경되었습니다.\n${result.destination}으로 이동됩니다.`);
|
|
closeStatusModal();
|
|
await loadIssues(); // 목록 새로고침
|
|
} else {
|
|
const error = await response.json();
|
|
throw new Error(error.detail || '상태 변경에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('상태 변경 오류:', error);
|
|
alert('상태 변경 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 유틸리티 함수들
|
|
function getStatusBadgeClass(status) {
|
|
const statusMap = {
|
|
'new': 'new',
|
|
'processing': 'processing',
|
|
'completed': 'completed',
|
|
'pending': 'processing'
|
|
};
|
|
return statusMap[status] || 'new';
|
|
}
|
|
|
|
function getStatusText(status) {
|
|
const statusMap = {
|
|
'new': '새 부적합',
|
|
'processing': '처리 중',
|
|
'completed': '완료',
|
|
'pending': '대기 중'
|
|
};
|
|
return statusMap[status] || status;
|
|
}
|
|
|
|
function getCategoryText(category) {
|
|
const categoryMap = {
|
|
'material_missing': '자재 누락',
|
|
'design_error': '설계 오류',
|
|
'incoming_defect': '반입 불량',
|
|
'inspection_miss': '검사 누락',
|
|
'etc': '기타'
|
|
};
|
|
return categoryMap[category] || category;
|
|
}
|
|
|
|
function getDisposalReasonText(reason) {
|
|
const reasonMap = {
|
|
'duplicate': '중복',
|
|
'invalid_report': '잘못된 신고',
|
|
'not_applicable': '해당 없음',
|
|
'spam': '스팸/오류',
|
|
'custom': '직접 입력'
|
|
};
|
|
return reasonMap[reason] || reason;
|
|
}
|
|
|
|
function getTimeAgo(date) {
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
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 date.toLocaleDateString('ko-KR');
|
|
}
|
|
|
|
function showLoading(show) {
|
|
const overlay = document.getElementById('loadingOverlay');
|
|
if (show) {
|
|
overlay.classList.add('active');
|
|
} else {
|
|
overlay.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
function showError(message) {
|
|
alert(message);
|
|
}
|
|
|
|
// API 스크립트 동적 로딩
|
|
const cacheBuster = Date.now() + Math.random() + Math.floor(Math.random() * 1000000);
|
|
const script = document.createElement('script');
|
|
script.src = `/static/js/api.js?v=20251025-2&cb=${cacheBuster}&t=${Date.now()}&r=${Math.random()}`;
|
|
script.setAttribute('cache-control', 'no-cache');
|
|
script.setAttribute('pragma', 'no-cache');
|
|
script.onload = function() {
|
|
console.log('✅ API 스크립트 로드 완료 (issues-inbox.html)');
|
|
initializeInbox();
|
|
};
|
|
script.onerror = function() {
|
|
console.error('❌ API 스크립트 로드 실패');
|
|
};
|
|
document.head.appendChild(script);
|
|
</script>
|
|
</body>
|
|
</html>
|