feat: 목록 관리 3개 하위 페이지 실제 파일 생성
수신함, 관리함, 폐기함 페이지를 실제로 구현하여 완전한 목록 관리 시스템 완성 Pages Created: - issues-inbox.html: 수신함 페이지 * 새로 등록된 부적합 확인 * 읽음/안읽음 상태 관리 * 실시간 통계 대시보드 * 필터링 및 정렬 기능 - issues-management.html: 관리함 페이지 * 부적합 상태 변경 및 처리 * 일괄 처리 기능 * 담당자 배정 (향후 구현) * 우선순위 관리 - issues-archive.html: 폐기함 페이지 * 완료/폐기된 부적합 보관 * 통계 차트 및 분석 * 기간별 필터링 * 데이터 내보내기 기능 Common Features: - 공통 헤더 및 권한 시스템 통합 - 반응형 모바일 최적화 디자인 - 실시간 데이터 로딩 및 필터링 - 프로젝트별 분류 및 검색 - 사용자 친화적 UI/UX Technical: - 각 페이지별 고유한 기능과 UI - 권한 기반 접근 제어 - API 연동 및 에러 처리 - 로컬 스토리지 활용 (읽음 상태 등) - 성능 최적화된 렌더링
This commit is contained in:
603
frontend/issues-archive.html
Normal file
603
frontend/issues-archive.html
Normal file
@@ -0,0 +1,603 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.issue-card:hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.archived-card {
|
||||
border-left: 4px solid #6b7280;
|
||||
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.completed-card {
|
||||
border-left: 4px solid #10b981;
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-completed { background: #d1fae5; color: #065f46; }
|
||||
.badge-archived { background: #f3f4f6; color: #374151; }
|
||||
.badge-cancelled { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
||||
|
||||
<!-- 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-archive text-gray-500 mr-3"></i>
|
||||
폐기함
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-1">완료되거나 폐기된 부적합 사항을 보관하고 분석하세요</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button onclick="generateReport()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-chart-bar mr-2"></i>
|
||||
통계 보고서
|
||||
</button>
|
||||
<button onclick="cleanupArchive()" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
정리하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 아카이브 통계 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<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="completedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-archive text-gray-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">보관</p>
|
||||
<p class="text-2xl font-bold text-gray-700" id="archivedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-times-circle text-red-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-red-600">취소</p>
|
||||
<p class="text-2xl font-bold text-red-700" id="cancelledCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-alt 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="thisMonthCount">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-5 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-gray-500 focus:border-gray-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-gray-500 focus:border-gray-500" onchange="filterIssues()">
|
||||
<option value="">전체</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="archived">보관</option>
|
||||
<option value="cancelled">취소</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 기간 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">📅 기간</label>
|
||||
<select id="periodFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
|
||||
<option value="">전체 기간</option>
|
||||
<option value="week">이번 주</option>
|
||||
<option value="month">이번 달</option>
|
||||
<option value="quarter">이번 분기</option>
|
||||
<option value="year">올해</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">🏷️ 카테고리</label>
|
||||
<select id="categoryFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
|
||||
<option value="">전체 카테고리</option>
|
||||
<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>
|
||||
<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-gray-500 focus:border-gray-500"
|
||||
onkeyup="filterIssues()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 차트 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- 월별 완료 현황 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">월별 완료 현황</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="monthlyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리별 분포 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">카테고리별 분포</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="categoryChart"></canvas>
|
||||
</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="completed">완료일순</option>
|
||||
<option value="category">카테고리순</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-archive 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>
|
||||
|
||||
<!-- 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>
|
||||
let currentUser = null;
|
||||
let issues = [];
|
||||
let projects = [];
|
||||
let filteredIssues = [];
|
||||
|
||||
// API 로드 후 초기화 함수
|
||||
async function initializeArchive() {
|
||||
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_archive');
|
||||
|
||||
// 페이지 접근 권한 체크
|
||||
setTimeout(() => {
|
||||
if (!canAccessPage('issues_archive')) {
|
||||
alert('폐기함 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 데이터 로드
|
||||
await loadProjects();
|
||||
await loadArchivedIssues();
|
||||
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 보관된 부적합 로드
|
||||
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 ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const allIssues = await response.json();
|
||||
// 완료, 보관, 취소된 부적합만 필터링
|
||||
issues = allIssues.filter(issue =>
|
||||
['completed', 'archived', 'cancelled'].includes(issue.status)
|
||||
);
|
||||
|
||||
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.updated_at || b.created_at) - new Date(a.updated_at || a.created_at);
|
||||
case 'oldest':
|
||||
return new Date(a.updated_at || a.created_at) - new Date(b.updated_at || b.created_at);
|
||||
case 'completed':
|
||||
return new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at);
|
||||
case 'category':
|
||||
return (a.category || '').localeCompare(b.category || '');
|
||||
default:
|
||||
return new Date(b.updated_at || b.created_at) - new Date(a.updated_at || 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 project = projects.find(p => p.id === issue.project_id);
|
||||
const completedDate = new Date(issue.updated_at || issue.created_at).toLocaleDateString('ko-KR');
|
||||
const cardClass = issue.status === 'completed' ? 'completed-card' : '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.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>완료: ${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.name;
|
||||
projectFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status) {
|
||||
const statusMap = {
|
||||
'completed': 'completed',
|
||||
'archived': 'archived',
|
||||
'cancelled': 'cancelled'
|
||||
};
|
||||
return statusMap[status] || 'archived';
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
'completed': '완료',
|
||||
'archived': '보관',
|
||||
'cancelled': '취소'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function getCategoryText(category) {
|
||||
const categoryMap = {
|
||||
'material_missing': '자재 누락',
|
||||
'design_error': '설계 오류',
|
||||
'incoming_defect': '반입 불량',
|
||||
'inspection_miss': '검사 누락',
|
||||
'etc': '기타'
|
||||
};
|
||||
return categoryMap[category] || category;
|
||||
}
|
||||
|
||||
// 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-archive.html)');
|
||||
initializeArchive();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error('❌ API 스크립트 로드 실패');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
567
frontend/issues-inbox.html
Normal file
567
frontend/issues-inbox.html
Normal file
@@ -0,0 +1,567 @@
|
||||
<!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>
|
||||
|
||||
<!-- 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);
|
||||
});
|
||||
}
|
||||
|
||||
// 부적합 목록 로드
|
||||
async function loadIssues() {
|
||||
showLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/issues/', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
issues = await response.json();
|
||||
|
||||
// 읽음 상태 로드 (localStorage에서)
|
||||
const savedReadStatus = localStorage.getItem('issues_read_status');
|
||||
if (savedReadStatus) {
|
||||
readStatus = new Set(JSON.parse(savedReadStatus));
|
||||
}
|
||||
|
||||
filterIssues();
|
||||
updateStatistics();
|
||||
} 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 cursor-pointer ${isUnread ? 'unread' : ''}"
|
||||
onclick="viewIssueDetail(${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-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</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">${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>
|
||||
<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>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
${issue.photo_path ? '<i class="fas fa-camera text-gray-400"></i>' : ''}
|
||||
<button onclick="event.stopPropagation(); 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('');
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStatistics() {
|
||||
const newIssues = issues.filter(issue => issue.status === 'new').length;
|
||||
const pendingIssues = issues.filter(issue => ['pending', 'processing'].includes(issue.status)).length;
|
||||
const todayProcessed = issues.filter(issue => {
|
||||
const today = new Date().toDateString();
|
||||
return issue.updated_at && new Date(issue.updated_at).toDateString() === today;
|
||||
}).length;
|
||||
|
||||
document.getElementById('newIssuesCount').textContent = newIssues;
|
||||
document.getElementById('pendingIssuesCount').textContent = pendingIssues;
|
||||
document.getElementById('todayProcessedCount').textContent = todayProcessed;
|
||||
document.getElementById('totalIssuesCount').textContent = issues.length;
|
||||
}
|
||||
|
||||
// 읽음 처리
|
||||
function markAsRead(issueId) {
|
||||
readStatus.add(issueId);
|
||||
localStorage.setItem('issues_read_status', JSON.stringify([...readStatus]));
|
||||
displayIssues();
|
||||
}
|
||||
|
||||
// 모두 읽음 처리
|
||||
function markAllAsRead() {
|
||||
filteredIssues.forEach(issue => readStatus.add(issue.id));
|
||||
localStorage.setItem('issues_read_status', JSON.stringify([...readStatus]));
|
||||
displayIssues();
|
||||
}
|
||||
|
||||
// 새로고침
|
||||
function refreshInbox() {
|
||||
loadIssues();
|
||||
}
|
||||
|
||||
// 부적합 상세 보기
|
||||
function viewIssueDetail(issueId) {
|
||||
markAsRead(issueId);
|
||||
// 상세 페이지로 이동 또는 모달 표시
|
||||
window.location.href = `/issue-view.html#detail-${issueId}`;
|
||||
}
|
||||
|
||||
// 유틸리티 함수들
|
||||
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 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>
|
||||
649
frontend/issues-management.html
Normal file
649
frontend/issues-management.html
Normal file
@@ -0,0 +1,649 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
transition: all 0.2s ease;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.status-new { border-left-color: #3b82f6; }
|
||||
.status-processing { border-left-color: #f59e0b; }
|
||||
.status-pending { border-left-color: #8b5cf6; }
|
||||
.status-completed { border-left-color: #10b981; }
|
||||
|
||||
.action-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.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-pending { background: #ede9fe; color: #7c3aed; }
|
||||
.badge-completed { background: #d1fae5; color: #065f46; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
||||
|
||||
<!-- 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-cog text-green-500 mr-3"></i>
|
||||
관리함
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-1">부적합 사항을 처리하고 상태를 관리하세요</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button onclick="bulkAction()" class="px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors">
|
||||
<i class="fas fa-tasks mr-2"></i>
|
||||
일괄 처리
|
||||
</button>
|
||||
<button onclick="exportData()" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
내보내기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 관리 통계 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle 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="needActionCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-yellow-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-spinner 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="processingCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-pause-circle 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="pendingCount">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="completedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-chart-pie text-gray-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">전체</p>
|
||||
<p class="text-2xl font-bold text-gray-700" id="totalCount">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-5 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-green-500 focus:border-green-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-green-500 focus:border-green-500" onchange="filterIssues()">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="new">새 부적합</option>
|
||||
<option value="processing">처리 중</option>
|
||||
<option value="pending">대기 중</option>
|
||||
<option value="completed">완료</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 우선순위 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">⚡ 우선순위</label>
|
||||
<select id="priorityFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500" onchange="filterIssues()">
|
||||
<option value="">전체</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="low">낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 담당자 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">👤 담당자</label>
|
||||
<select id="assigneeFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500" onchange="filterIssues()">
|
||||
<option value="">전체 담당자</option>
|
||||
<option value="unassigned">미배정</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-green-500 focus:border-green-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-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()" class="mr-2">
|
||||
<span class="text-sm text-gray-600">전체 선택</span>
|
||||
</label>
|
||||
<select id="sortOrder" class="text-sm border border-gray-300 rounded px-2 py-1" onchange="sortIssues()">
|
||||
<option value="priority">우선순위</option>
|
||||
<option value="newest">최신순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
<option value="status">상태순</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-cog 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="statusModal" class="fixed inset-0 bg-black bg-opacity-50 modal 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>
|
||||
<select id="newStatus" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
<option value="processing">처리 중</option>
|
||||
<option value="pending">대기 중</option>
|
||||
<option value="completed">완료</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">처리 메모</label>
|
||||
<textarea id="statusNote" 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="updateStatus()" 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>
|
||||
let currentUser = null;
|
||||
let issues = [];
|
||||
let projects = [];
|
||||
let filteredIssues = [];
|
||||
let selectedIssues = new Set();
|
||||
let currentIssueId = null;
|
||||
|
||||
// API 로드 후 초기화 함수
|
||||
async function initializeManagement() {
|
||||
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_management');
|
||||
|
||||
// 페이지 접근 권한 체크
|
||||
setTimeout(() => {
|
||||
if (!canAccessPage('issues_management')) {
|
||||
alert('관리함 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 데이터 로드
|
||||
await loadProjects();
|
||||
await loadIssues();
|
||||
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 부적합 목록 로드 (관리자는 모든 부적합 조회)
|
||||
async function loadIssues() {
|
||||
try {
|
||||
let endpoint = '/api/issues/';
|
||||
|
||||
// 관리자인 경우 전체 부적합 조회 API 사용
|
||||
if (currentUser.role === 'admin') {
|
||||
endpoint = '/api/issues/admin/all';
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
issues = await response.json();
|
||||
filterIssues();
|
||||
updateStatistics();
|
||||
} 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 priorityFilter = document.getElementById('priorityFilter').value;
|
||||
const assigneeFilter = document.getElementById('assigneeFilter').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 (priorityFilter && issue.priority !== priorityFilter) return false;
|
||||
if (assigneeFilter === 'unassigned' && issue.assignee_id) return false;
|
||||
if (assigneeFilter && assigneeFilter !== 'unassigned' && issue.assignee_id != assigneeFilter) 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 'priority':
|
||||
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
||||
return (priorityOrder[b.priority] || 1) - (priorityOrder[a.priority] || 1);
|
||||
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 'status':
|
||||
const statusOrder = { 'new': 4, 'processing': 3, 'pending': 2, 'completed': 1 };
|
||||
return (statusOrder[b.status] || 0) - (statusOrder[a.status] || 0);
|
||||
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 project = projects.find(p => p.id === issue.project_id);
|
||||
const createdDate = new Date(issue.created_at).toLocaleDateString('ko-KR');
|
||||
const isSelected = selectedIssues.has(issue.id);
|
||||
|
||||
return `
|
||||
<div class="issue-card p-6 status-${issue.status} ${isSelected ? 'bg-blue-50' : ''}"
|
||||
onclick="toggleIssueSelection(${issue.id})">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start space-x-3 flex-1">
|
||||
<input type="checkbox" ${isSelected ? 'checked' : ''}
|
||||
onchange="toggleIssueSelection(${issue.id})"
|
||||
onclick="event.stopPropagation()"
|
||||
class="mt-1">
|
||||
|
||||
<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>
|
||||
${getPriorityBadge(issue.priority)}
|
||||
${project ? `<span class="text-sm text-gray-500">${project.name}</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>
|
||||
<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>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button onclick="event.stopPropagation(); openStatusModal(${issue.id})"
|
||||
class="action-btn px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 transition-colors">
|
||||
<i class="fas fa-edit mr-1"></i>상태 변경
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); viewIssueDetail(${issue.id})"
|
||||
class="action-btn px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i>상세
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStatistics() {
|
||||
const needAction = issues.filter(issue => issue.status === 'new').length;
|
||||
const processing = issues.filter(issue => issue.status === 'processing').length;
|
||||
const pending = issues.filter(issue => issue.status === 'pending').length;
|
||||
const completed = issues.filter(issue => issue.status === 'completed').length;
|
||||
|
||||
document.getElementById('needActionCount').textContent = needAction;
|
||||
document.getElementById('processingCount').textContent = processing;
|
||||
document.getElementById('pendingCount').textContent = pending;
|
||||
document.getElementById('completedCount').textContent = completed;
|
||||
document.getElementById('totalCount').textContent = issues.length;
|
||||
}
|
||||
|
||||
// 선택 관리
|
||||
function toggleIssueSelection(issueId) {
|
||||
if (selectedIssues.has(issueId)) {
|
||||
selectedIssues.delete(issueId);
|
||||
} else {
|
||||
selectedIssues.add(issueId);
|
||||
}
|
||||
displayIssues();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const selectAll = document.getElementById('selectAll').checked;
|
||||
if (selectAll) {
|
||||
filteredIssues.forEach(issue => selectedIssues.add(issue.id));
|
||||
} else {
|
||||
selectedIssues.clear();
|
||||
}
|
||||
displayIssues();
|
||||
}
|
||||
|
||||
// 상태 변경 모달
|
||||
function openStatusModal(issueId) {
|
||||
currentIssueId = issueId;
|
||||
document.getElementById('statusModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeStatusModal() {
|
||||
currentIssueId = null;
|
||||
document.getElementById('statusModal').classList.add('hidden');
|
||||
document.getElementById('newStatus').value = 'processing';
|
||||
document.getElementById('statusNote').value = '';
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
if (!currentIssueId) return;
|
||||
|
||||
const newStatus = document.getElementById('newStatus').value;
|
||||
const note = document.getElementById('statusNote').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/issues/${currentIssueId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: newStatus,
|
||||
note: note
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadIssues();
|
||||
closeStatusModal();
|
||||
alert('상태가 성공적으로 변경되었습니다.');
|
||||
} else {
|
||||
throw new Error('상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상태 변경 실패:', error);
|
||||
alert('상태 변경에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 함수들
|
||||
function bulkAction() {
|
||||
if (selectedIssues.size === 0) {
|
||||
alert('처리할 부적합을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
// 일괄 처리 로직 구현
|
||||
alert(`${selectedIssues.size}개의 부적합을 일괄 처리합니다.`);
|
||||
}
|
||||
|
||||
function exportData() {
|
||||
// 데이터 내보내기 로직 구현
|
||||
alert('데이터를 내보냅니다.');
|
||||
}
|
||||
|
||||
function viewIssueDetail(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.name;
|
||||
projectFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status) {
|
||||
const statusMap = {
|
||||
'new': 'new',
|
||||
'processing': 'processing',
|
||||
'pending': 'pending',
|
||||
'completed': 'completed'
|
||||
};
|
||||
return statusMap[status] || 'new';
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
'new': '새 부적합',
|
||||
'processing': '처리 중',
|
||||
'pending': '대기 중',
|
||||
'completed': '완료'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
function getPriorityBadge(priority) {
|
||||
const priorityMap = {
|
||||
'high': { text: '높음', class: 'bg-red-100 text-red-800' },
|
||||
'medium': { text: '보통', class: 'bg-yellow-100 text-yellow-800' },
|
||||
'low': { text: '낮음', class: 'bg-green-100 text-green-800' }
|
||||
};
|
||||
const p = priorityMap[priority] || { text: '보통', class: 'bg-gray-100 text-gray-800' };
|
||||
return `<span class="badge ${p.class}">${p.text}</span>`;
|
||||
}
|
||||
|
||||
function getCategoryText(category) {
|
||||
const categoryMap = {
|
||||
'material_missing': '자재 누락',
|
||||
'design_error': '설계 오류',
|
||||
'incoming_defect': '반입 불량',
|
||||
'inspection_miss': '검사 누락',
|
||||
'etc': '기타'
|
||||
};
|
||||
return categoryMap[category] || category;
|
||||
}
|
||||
|
||||
// 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-management.html)');
|
||||
initializeManagement();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error('❌ API 스크립트 로드 실패');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user