refactor: 코드 분리 + 성능 최적화 + 모바일 개선

tkqc 5개 페이지 인라인 JS/CSS를 외부 파일로 추출 (HTML 82% 감소)
tkuser index.html을 CSS 1개 + JS 10개 모듈로 분리 (3283→1155줄)

- 공통 유틸 추출: issue-helpers, photo-modal, toast
- 공통 CSS 확장: tkqc-common.css (모바일 반응형 포함)
- 모바일 하단 네비게이션 추가 (mobile-bottom-nav.js)
- nginx: JS/CSS 1시간 캐싱 + gzip 압축 활성화
- Tailwind CDN preload, 캐시버스터 통일 (?v=20260213)
- 카메라 capture="environment" 추가
- tkuser Dockerfile에 static/ 디렉토리 복사 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-13 11:58:22 +09:00
parent c52734154f
commit bf4000c4ae
35 changed files with 9162 additions and 9129 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,36 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>폐기함 - 작업보고서</title>
<!-- Tailwind CSS -->
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<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">
<!-- 공통 스타일 -->
<link rel="stylesheet" href="/static/css/tkqc-common.css">
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=20260213">
<!-- Custom Styles -->
<style>
.archived-card {
border-left: 4px solid #6b7280;
background: #f8fafc;
}
.completed-card {
border-left: 4px solid #10b981;
background: #f0fdf4;
}
.chart-container {
position: relative;
height: 300px;
}
</style>
<!-- 페이지 전용 스타일 -->
<link rel="stylesheet" href="/static/css/issues-archive.css?v=20260213">
</head>
<body>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
@@ -61,7 +47,7 @@
</button>
</div>
</div>
<!-- 아카이브 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-green-50 p-4 rounded-lg">
@@ -113,7 +99,7 @@
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📋 상태</label>
@@ -124,7 +110,7 @@
<option value="cancelled">취소</option>
</select>
</div>
<!-- 기간 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📅 기간</label>
@@ -136,7 +122,7 @@
<option value="year">올해</option>
</select>
</div>
<!-- 카테고리 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">🏷️ 카테고리</label>
@@ -149,11 +135,11 @@
<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="설명 또는 등록자 검색..."
<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>
@@ -169,7 +155,7 @@
<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>
@@ -195,11 +181,11 @@
</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>
@@ -210,371 +196,14 @@
</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 = TokenManager.getToken();
if (!token) {
window.location.href = '/index.html';
return;
}
try {
const user = await AuthAPI.getCurrentUser();
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
// 공통 헤더 초기화
await window.commonHeader.init(user, 'issues_archive');
// 페이지 접근 권한 체크
setTimeout(() => {
if (!canAccessPage('issues_archive')) {
alert('폐기함 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
}
}, 500);
// 데이터 로드
await loadProjects();
await loadArchivedIssues();
} catch (error) {
console.error('인증 실패:', error);
TokenManager.removeToken();
TokenManager.removeUser();
window.location.href = '/index.html';
}
}
// 프로젝트 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
projects = await response.json();
updateProjectFilter();
}
} catch (error) {
console.error('프로젝트 로드 실패:', error);
}
}
// 보관된 부적합 로드
async function loadArchivedIssues() {
try {
let endpoint = '/api/issues/';
// 관리자인 경우 전체 부적합 조회 API 사용
if (currentUser.role === 'admin') {
endpoint = '/api/issues/admin/all';
}
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const allIssues = await response.json();
// 폐기된 부적합만 필터링 (폐기함 전용)
issues = allIssues.filter(issue =>
issue.review_status === 'disposed'
);
filterIssues();
updateStatistics();
renderCharts();
} else {
throw new Error('부적합 목록을 불러올 수 없습니다.');
}
} catch (error) {
console.error('부적합 로드 실패:', error);
alert('부적합 목록을 불러오는데 실패했습니다.');
}
}
// 필터링 및 표시
function filterIssues() {
const projectFilter = document.getElementById('projectFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
const periodFilter = document.getElementById('periodFilter').value;
const categoryFilter = document.getElementById('categoryFilter').value;
const searchInput = document.getElementById('searchInput').value.toLowerCase();
filteredIssues = issues.filter(issue => {
if (projectFilter && issue.project_id != projectFilter) return false;
if (statusFilter && issue.status !== statusFilter) return false;
if (categoryFilter && issue.category !== categoryFilter) return false;
// 기간 필터
if (periodFilter) {
const issueDate = new Date(issue.updated_at || issue.created_at);
const now = new Date();
switch (periodFilter) {
case 'week':
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
if (issueDate < weekAgo) return false;
break;
case 'month':
const monthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
if (issueDate < monthAgo) return false;
break;
case 'quarter':
const quarterAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
if (issueDate < quarterAgo) return false;
break;
case 'year':
const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
if (issueDate < yearAgo) return false;
break;
}
}
if (searchInput) {
const searchText = `${issue.description} ${issue.reporter?.username || ''}`.toLowerCase();
if (!searchText.includes(searchInput)) return false;
}
return true;
});
sortIssues();
displayIssues();
}
function sortIssues() {
const sortOrder = document.getElementById('sortOrder').value;
filteredIssues.sort((a, b) => {
switch (sortOrder) {
case 'newest':
return new Date(b.report_date) - new Date(a.report_date);
case 'oldest':
return new Date(a.report_date) - new Date(b.report_date);
case 'completed':
return new Date(b.disposed_at || b.report_date) - new Date(a.disposed_at || a.report_date);
case 'category':
return (a.category || '').localeCompare(b.category || '');
default:
return new Date(b.report_date) - new Date(a.report_date);
}
});
}
function displayIssues() {
const container = document.getElementById('issuesList');
const emptyState = document.getElementById('emptyState');
if (filteredIssues.length === 0) {
container.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
container.innerHTML = filteredIssues.map(issue => {
const project = projects.find(p => p.id === issue.project_id);
// 폐기함은 폐기된 것만 표시
const completedDate = issue.disposed_at ? new Date(issue.disposed_at).toLocaleDateString('ko-KR') : 'Invalid Date';
const statusText = '폐기';
const cardClass = 'archived-card';
return `
<div class="issue-card p-6 ${cardClass} cursor-pointer"
onclick="viewArchivedIssue(${issue.id})">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<span class="badge badge-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</span>
${project ? `<span class="text-sm text-gray-500">${project.project_name}</span>` : ''}
<span class="text-sm text-gray-400">${completedDate}</span>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">${issue.description}</h3>
<div class="flex items-center text-sm text-gray-500 space-x-4">
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span>
${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''}
<span><i class="fas fa-clock mr-1"></i>${statusText}: ${completedDate}</span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<i class="fas fa-${getStatusIcon(issue.status)} text-2xl ${getStatusColor(issue.status)}"></i>
</div>
</div>
</div>
`;
}).join('');
}
// 통계 업데이트
function updateStatistics() {
const completed = issues.filter(issue => issue.status === 'completed').length;
const archived = issues.filter(issue => issue.status === 'archived').length;
const cancelled = issues.filter(issue => issue.status === 'cancelled').length;
const thisMonth = issues.filter(issue => {
const issueDate = new Date(issue.updated_at || issue.created_at);
const now = new Date();
return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear();
}).length;
document.getElementById('completedCount').textContent = completed;
document.getElementById('archivedCount').textContent = archived;
document.getElementById('cancelledCount').textContent = cancelled;
document.getElementById('thisMonthCount').textContent = thisMonth;
}
// 차트 렌더링 (간단한 텍스트 기반)
function renderCharts() {
renderMonthlyChart();
renderCategoryChart();
}
function renderMonthlyChart() {
const canvas = document.getElementById('monthlyChart');
const ctx = canvas.getContext('2d');
// 간단한 차트 대신 텍스트로 표시
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
ctx.fillStyle = '#374151';
ctx.font = '16px Inter';
ctx.textAlign = 'center';
ctx.fillText('월별 완료 현황 차트', canvas.width / 2, canvas.height / 2);
ctx.font = '12px Inter';
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
}
function renderCategoryChart() {
const canvas = document.getElementById('categoryChart');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
ctx.fillStyle = '#374151';
ctx.font = '16px Inter';
ctx.textAlign = 'center';
ctx.fillText('카테고리별 분포 차트', canvas.width / 2, canvas.height / 2);
ctx.font = '12px Inter';
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
}
// 기타 함수들
function generateReport() {
alert('통계 보고서를 생성합니다.');
}
function cleanupArchive() {
if (confirm('오래된 보관 데이터를 정리하시겠습니까?')) {
alert('데이터 정리가 완료되었습니다.');
}
}
function viewArchivedIssue(issueId) {
window.location.href = `/issue-view.html#detail-${issueId}`;
}
// 유틸리티 함수들
function updateProjectFilter() {
const projectFilter = document.getElementById('projectFilter');
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name;
projectFilter.appendChild(option);
});
}
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>
<script src="/static/js/date-utils.js?v=20260213"></script>
<script src="/static/js/core/permissions.js?v=20260213"></script>
<script src="/static/js/components/common-header.js?v=20260213"></script>
<script src="/static/js/core/page-manager.js?v=20260213"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260213"></script>
<script src="/static/js/utils/photo-modal.js?v=20260213"></script>
<script src="/static/js/utils/toast.js?v=20260213"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=20260213"></script>
<script src="/static/js/pages/issues-archive.js?v=20260213"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,21 @@ server {
root /usr/share/nginx/html;
index issues-dashboard.html;
# gzip 압축
gzip on;
gzip_types text/plain text/css application/javascript application/json;
gzip_min_length 1024;
# HTML 캐시 비활성화
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# JS/CSS 캐시 활성화
# JS/CSS 캐시 활성화 (버전 쿼리 스트링으로 무효화)
location ~* \.(js|css)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
expires 1h;
add_header Cache-Control "public, no-transform";
}
# 정적 파일 (이미지 등)

View File

@@ -0,0 +1,49 @@
/* issue-view.css — 부적합 조회 페이지 전용 스타일 */
body {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
min-height: 100vh;
}
.glass-effect {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.input-field {
background: white;
border: 1px solid #e5e7eb;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.nav-link {
color: #6b7280;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
text-decoration: none;
}
.nav-link:hover {
background-color: #f3f4f6;
color: #3b82f6;
}
.nav-link.active {
background-color: #3b82f6;
color: white;
}

View File

@@ -0,0 +1,16 @@
/* issues-archive.css — 폐기함 페이지 전용 스타일 */
.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%);
}
.chart-container {
position: relative;
height: 300px;
}

View File

@@ -0,0 +1,73 @@
/* issues-dashboard.css — 현황판 페이지 전용 스타일 */
/* 대시보드 페이지는 @keyframes 기반 애니메이션 사용 (공통 CSS와 다른 방식) */
.fade-in { opacity: 0; animation: fadeIn 0.5s ease-in forwards; }
@keyframes fadeIn { to { opacity: 1; } }
.header-fade-in { opacity: 0; animation: headerFadeIn 0.6s ease-out forwards; }
@keyframes headerFadeIn { to { opacity: 1; transform: translateY(0); } from { transform: translateY(-10px); } }
.content-fade-in { opacity: 0; animation: contentFadeIn 0.7s ease-out 0.2s forwards; }
@keyframes contentFadeIn { to { opacity: 1; transform: translateY(0); } from { transform: translateY(20px); } }
/* 대시보드 카드 스타일 */
.dashboard-card {
transition: all 0.2s ease;
background: #ffffff;
border-left: 4px solid #64748b;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 이슈 카드 스타일 (대시보드 전용 오버라이드) */
.issue-card {
transition: all 0.2s ease;
border-left: 4px solid transparent;
background: #ffffff;
}
.issue-card:hover {
transform: translateY(-2px);
border-left-color: #475569;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.issue-card label {
font-weight: 600;
color: #374151;
}
.issue-card .bg-gray-50 {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
}
.issue-card .bg-gray-50:hover {
background-color: #f3f4f6;
}
.issue-card .fas.fa-image:hover {
transform: scale(1.2);
color: #3b82f6;
}
/* 진행 중 애니메이션 */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* 반응형 그리드 */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}

View File

@@ -0,0 +1,6 @@
/* issues-inbox.css — 수신함 페이지 전용 스타일 */
.issue-card.unread {
border-left-color: #3b82f6;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
}

View File

@@ -0,0 +1,123 @@
/* issues-management.css — 관리함 페이지 전용 스타일 */
/* 액션 버튼 */
.action-btn {
transition: all 0.2s ease;
}
.action-btn:hover {
transform: scale(1.05);
}
/* 모달 블러 */
.modal {
backdrop-filter: blur(4px);
}
/* 이슈 테이블 컬럼 헤더 */
.issue-table th {
background-color: #f9fafb;
font-weight: 600;
color: #374151;
font-size: 0.875rem;
white-space: nowrap;
}
.issue-table tbody tr:hover {
background-color: #f9fafb;
}
/* 컬럼별 너비 조정 */
.col-no { min-width: 60px; }
.col-project { min-width: 120px; }
.col-content { min-width: 250px; max-width: 300px; }
.col-cause { min-width: 100px; }
.col-solution { min-width: 200px; max-width: 250px; }
.col-department { min-width: 100px; }
.col-person { min-width: 120px; }
.col-date { min-width: 120px; }
.col-confirmer { min-width: 120px; }
.col-comment { min-width: 200px; max-width: 250px; }
.col-status { min-width: 100px; }
.col-photos { min-width: 150px; }
.col-completion { min-width: 80px; }
.col-actions { min-width: 120px; }
/* 이슈 사진 */
.issue-photo {
width: 60px;
height: 40px;
object-fit: cover;
border-radius: 0.375rem;
cursor: pointer;
margin: 2px;
}
.photo-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* 편집 가능한 필드 스타일 */
.editable-field {
min-width: 100%;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.875rem;
}
.editable-field:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.text-wrap {
white-space: normal;
word-wrap: break-word;
line-height: 1.4;
}
.btn-sm {
padding: 4px 8px;
font-size: 0.75rem;
border-radius: 4px;
margin: 2px;
white-space: nowrap;
min-width: fit-content;
}
/* 관리함 전용 collapse-content (max-height 기반 트랜지션) */
.collapse-content {
max-height: 1000px;
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.collapse-content.collapsed {
max-height: 0;
}
/* 관리함 전용 이슈 카드 오버라이드 */
.issue-card label {
font-weight: 500;
}
.issue-card input:focus,
.issue-card select:focus,
.issue-card textarea:focus {
transform: scale(1.01);
transition: transform 0.1s ease;
}
.issue-card .bg-gray-50 {
border-left: 4px solid #e5e7eb;
}
/* 카드 내 아이콘 스타일 */
.issue-card i {
width: 16px;
text-align: center;
}

View File

@@ -1,39 +1,404 @@
/* tkqc-common.css — tkuser 스타일 통일 */
/* tkqc-common.css — 부적합 관리 시스템 공통 스타일 */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f1f5f9;
min-height: 100vh;
}
.input-field {
background: white;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: #64748b;
box-shadow: 0 0 0 3px rgba(100,116,139,0.1);
/* ===== 로딩 오버레이 ===== */
.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;
}
.issue-card {
.loading-overlay.active {
opacity: 1;
visibility: visible;
}
/* ===== 날짜 그룹 ===== */
.date-group {
margin-bottom: 1.5rem;
}
.date-header {
cursor: pointer;
transition: all 0.2s ease;
border-left: 4px solid transparent;
}
.issue-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.badge { display: inline-flex; align-items: center; padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
.date-header:hover {
background-color: #f3f4f6 !important;
}
.collapse-content {
transition: all 0.3s ease;
}
.collapse-content.collapsed {
display: none;
}
/* ===== 우선순위 표시 ===== */
.priority-high { border-left-color: #ef4444 !important; }
.priority-medium { border-left-color: #f59e0b !important; }
.priority-low { border-left-color: #10b981 !important; }
/* ===== 상태 배지 ===== */
.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: #fef3c7; color: #92400e; }
.badge-completed { background: #d1fae5; color: #065f46; }
.badge-archived { background: #f3f4f6; color: #374151; }
.badge-cancelled { background: #fee2e2; color: #991b1b; }
.fade-in { opacity: 0; transform: translateY(10px); transition: opacity 0.4s, transform 0.4s; }
.fade-in.visible { opacity: 1; transform: translateY(0); }
/* ===== 이슈 카드 ===== */
.issue-card {
transition: all 0.2s ease;
border-left: 4px solid transparent;
}
.toast-message { transition: all 0.3s ease; }
.issue-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
/* ===== 사진 프리뷰 ===== */
.photo-preview {
max-width: 150px;
max-height: 100px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s ease;
}
.photo-preview:hover {
transform: scale(1.05);
}
.photo-gallery {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
/* ===== 사진 모달 ===== */
.photo-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
}
.photo-modal-content {
position: relative;
max-width: 90%;
max-height: 90vh;
}
.photo-modal-content img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
}
.photo-modal-close {
position: absolute;
top: -12px;
right: -12px;
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.photo-modal-close:hover {
background: white;
}
/* ===== 페이드인 애니메이션 ===== */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
.header-fade-in {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.header-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
.content-fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
transition-delay: 0.2s;
}
.content-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* ===== 프로그레스바 ===== */
.progress-bar {
background: #475569;
transition: width 0.8s ease;
}
/* ===== 모달 공통 ===== */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
}
/* ===== 이슈 테이블 ===== */
.issue-table-container {
overflow-x: auto;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
margin-top: 0.5rem;
}
.issue-table {
min-width: 2000px;
width: 100%;
border-collapse: collapse;
}
.issue-table th,
.issue-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
/* ===== 상태 보더 ===== */
.status-new { border-left-color: #3b82f6; }
.status-processing { border-left-color: #f59e0b; }
.status-pending { border-left-color: #8b5cf6; }
.status-completed { border-left-color: #10b981; }
/* ===== 탭 스크롤 인디케이터 ===== */
.tab-scroll-container {
position: relative;
overflow: hidden;
}
.tab-scroll-container::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 40px;
background: linear-gradient(to right, transparent, white);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.tab-scroll-container.has-overflow::after {
opacity: 1;
}
.tab-scroll-inner {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tab-scroll-inner::-webkit-scrollbar {
display: none;
}
/* ===== 모바일 하단 네비게이션 ===== */
.tkqc-mobile-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: #ffffff;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
}
.tkqc-mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
height: 100%;
text-decoration: none;
color: #6b7280;
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.tkqc-mobile-nav-item i {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.tkqc-mobile-nav-item:active {
background: #f3f4f6;
}
.tkqc-mobile-nav-item.active {
color: #2563eb;
font-weight: 600;
}
.tkqc-mobile-nav-item.active i {
transform: scale(1.1);
}
/* ===== 모바일 반응형 ===== */
@media (max-width: 768px) {
/* 터치 타겟 최소 44px */
button, a, [onclick], select {
min-height: 44px;
min-width: 44px;
}
.tab-btn {
padding: 12px 16px;
font-size: 14px;
}
.photo-preview {
max-width: 80px;
max-height: 60px;
}
.photo-modal-content {
max-width: 95%;
}
.badge {
font-size: 0.65rem;
padding: 0.2rem 0.5rem;
}
/* 하단 네비게이션 표시 */
.tkqc-mobile-nav {
display: flex;
align-items: center;
justify-content: space-around;
}
body {
padding-bottom: calc(64px + env(safe-area-inset-bottom)) !important;
}
/* 테이블 → 카드 변환 */
.issue-table {
min-width: unset;
}
.issue-table thead {
display: none;
}
.issue-table tr {
display: block;
margin-bottom: 1rem;
border-radius: 0.5rem;
padding: 0.75rem;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border-left: 4px solid #3b82f6;
}
.issue-table td {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
border-bottom: none;
}
.issue-table td::before {
content: attr(data-label);
font-weight: 600;
color: #374151;
margin-right: 1rem;
flex-shrink: 0;
}
/* 대시보드 그리드 모바일 */
.dashboard-grid {
grid-template-columns: 1fr;
}
/* 2x2 그리드를 1열로 */
.grid-cols-2 {
grid-template-columns: 1fr !important;
}
/* 이슈 카드 터치 최적화 */
.issue-card {
padding: 1rem;
}
.issue-card:hover {
transform: none;
}
}

View File

@@ -0,0 +1,34 @@
/**
* mobile-bottom-nav.js — tkqc 모바일 하단 네비게이션
* 768px 이하에서 고정 하단바 표시
*/
(function() {
// 이미 삽입되었으면 스킵
if (document.getElementById('tkqcMobileNav')) return;
const nav = document.createElement('nav');
nav.id = 'tkqcMobileNav';
nav.className = 'tkqc-mobile-nav';
const currentPath = window.location.pathname;
const items = [
{ href: '/issues-dashboard.html', icon: 'fas fa-chart-line', label: '현황판', page: 'dashboard' },
{ href: '/issues-inbox.html', icon: 'fas fa-inbox', label: '수신함', page: 'inbox' },
{ href: '/issues-management.html', icon: 'fas fa-tasks', label: '관리함', page: 'management' },
{ href: '/issues-archive.html', icon: 'fas fa-archive', label: '폐기함', page: 'archive' }
];
nav.innerHTML = items.map(item => {
const isActive = currentPath.includes(item.page) || currentPath === item.href;
return `
<a href="${item.href}" class="tkqc-mobile-nav-item ${isActive ? 'active' : ''}">
<i class="${item.icon}"></i>
<span>${item.label}</span>
</a>
`;
}).join('');
document.body.appendChild(nav);
})();

View File

@@ -0,0 +1,886 @@
/**
* issue-view.js — 부적합 조회 페이지 스크립트
*/
let currentUser = null;
let issues = [];
let projects = []; // 프로젝트 데이터 캐시
let currentRange = 'week'; // 기본값: 이번 주
// 애니메이션 함수들
function animateHeaderAppearance() {
console.log('헤더 애니메이션 시작');
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
if (headerElement) {
headerElement.classList.add('header-fade-in');
setTimeout(() => {
headerElement.classList.add('visible');
// 헤더 애니메이션 완료 후 본문 애니메이션
setTimeout(() => {
animateContentAppearance();
}, 200);
}, 50);
} else {
// 헤더를 찾지 못했으면 바로 본문 애니메이션
animateContentAppearance();
}
}
// 본문 컨텐츠 애니메이션
function animateContentAppearance() {
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
const contentElements = document.querySelectorAll('.content-fade-in');
contentElements.forEach((element, index) => {
setTimeout(() => {
element.classList.add('visible');
}, index * 100); // 100ms씩 지연
});
}
// API 로드 후 초기화 함수
async function initializeIssueView() {
const token = TokenManager.getToken();
if (!token) {
window.location.href = '/index.html';
return;
}
try {
const user = await AuthAPI.getCurrentUser();
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
// 공통 헤더 초기화
await window.commonHeader.init(user, 'issues_view');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
// 사용자 역할에 따른 페이지 제목 설정
updatePageTitle(user);
// 페이지 접근 권한 체크 (부적합 조회 페이지)
setTimeout(() => {
if (!canAccessPage('issues_view')) {
alert('부적합 조회 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
}
}, 500);
} catch (error) {
console.error('인증 실패:', error);
TokenManager.removeToken();
TokenManager.removeUser();
window.location.href = '/index.html';
return;
}
// 프로젝트 로드
await loadProjects();
// 기본 날짜 설정 (이번 주)
setDefaultDateRange();
// 기본값: 이번 주 데이터 로드
await loadIssues();
setDateRange('week');
}
// showImageModal은 photo-modal.js에서 제공됨
// 기본 날짜 범위 설정
function setDefaultDateRange() {
const today = new Date();
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
// 날짜 입력 필드에 기본값 설정
document.getElementById('startDateInput').value = formatDateForInput(weekStart);
document.getElementById('endDateInput').value = formatDateForInput(today);
}
// 날짜를 input[type="date"] 형식으로 포맷
function formatDateForInput(date) {
return date.toISOString().split('T')[0];
}
// 날짜 필터 적용
function applyDateFilter() {
const startDate = document.getElementById('startDateInput').value;
const endDate = document.getElementById('endDateInput').value;
if (!startDate || !endDate) {
alert('시작날짜와 끝날짜를 모두 선택해주세요.');
return;
}
if (new Date(startDate) > new Date(endDate)) {
alert('시작날짜는 끝날짜보다 이전이어야 합니다.');
return;
}
// 필터 적용
filterIssues();
}
// 사용자 역할에 따른 페이지 제목 업데이트
function updatePageTitle(user) {
const titleElement = document.getElementById('pageTitle');
const descriptionElement = document.getElementById('pageDescription');
if (user.role === 'admin') {
titleElement.innerHTML = `
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
전체 부적합 조회
`;
descriptionElement.textContent = '모든 사용자가 등록한 부적합 사항을 관리할 수 있습니다';
} else {
titleElement.innerHTML = `
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
내 부적합 조회
`;
descriptionElement.textContent = '내가 등록한 부적합 사항을 확인할 수 있습니다';
}
}
// 프로젝트 로드 (API 기반)
async function loadProjects() {
try {
// 모든 프로젝트 로드 (활성/비활성 모두 - 기존 데이터 조회를 위해)
projects = await ProjectsAPI.getAll(false);
const projectFilter = document.getElementById('projectFilter');
// 기존 옵션 제거 (전체 프로젝트 옵션 제외)
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
// 모든 프로젝트 추가
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.job_no} / ${project.project_name}${!project.is_active ? ' (비활성)' : ''}`;
projectFilter.appendChild(option);
});
} catch (error) {
console.error('프로젝트 로드 실패:', error);
}
}
// 이슈 필터링
// 검토 상태 확인 함수
function isReviewCompleted(issue) {
return issue.status === 'complete' && issue.work_hours && issue.work_hours > 0;
}
// 날짜 필터링 함수
function filterByDate(issues, dateFilter) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
switch (dateFilter) {
case 'today':
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= today;
});
case 'week':
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= weekStart;
});
case 'month':
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= monthStart;
});
default:
return issues;
}
}
// 날짜 범위별 필터링 함수
function filterByDateRange(issues, range) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
switch (range) {
case 'today':
return issues.filter(issue => {
const issueDate = new Date(issue.created_at);
const issueDay = new Date(issueDate.getFullYear(), issueDate.getMonth(), issueDate.getDate());
return issueDay.getTime() === today.getTime();
});
case 'week':
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
return issues.filter(issue => {
const issueDate = new Date(issue.created_at);
return issueDate >= weekStart && issueDate <= weekEnd;
});
case 'month':
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
monthEnd.setHours(23, 59, 59, 999);
return issues.filter(issue => {
const issueDate = new Date(issue.created_at);
return issueDate >= monthStart && issueDate <= monthEnd;
});
default:
return issues;
}
}
function filterIssues() {
// 필터 값 가져오기
const selectedProjectId = document.getElementById('projectFilter').value;
const reviewStatusFilter = document.getElementById('reviewStatusFilter').value;
let filteredIssues = [...issues];
// 프로젝트 필터 적용
if (selectedProjectId) {
filteredIssues = filteredIssues.filter(issue => {
const issueProjectId = issue.project_id || issue.projectId;
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
});
}
// 워크플로우 상태 필터 적용
if (reviewStatusFilter) {
filteredIssues = filteredIssues.filter(issue => {
// 새로운 워크플로우 시스템 사용
if (issue.review_status) {
return issue.review_status === reviewStatusFilter;
}
// 기존 데이터 호환성을 위한 폴백
else {
const isCompleted = isReviewCompleted(issue);
if (reviewStatusFilter === 'pending_review') return !isCompleted;
if (reviewStatusFilter === 'completed') return isCompleted;
return false;
}
});
}
// 날짜 범위 필터 적용 (입력 필드에서 선택된 범위)
const startDateInput = document.getElementById('startDateInput').value;
const endDateInput = document.getElementById('endDateInput').value;
if (startDateInput && endDateInput) {
filteredIssues = filteredIssues.filter(issue => {
const issueDate = new Date(issue.report_date);
const startOfDay = new Date(startDateInput);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(endDateInput);
endOfDay.setHours(23, 59, 59, 999);
return issueDate >= startOfDay && issueDate <= endOfDay;
});
}
// 전역 변수에 필터링된 결과 저장
window.filteredIssues = filteredIssues;
displayResults();
}
// 프로젝트 정보 표시용 함수
function getProjectInfo(projectId) {
if (!projectId) {
return '<span class="text-gray-500">프로젝트 미지정</span>';
}
// 전역 projects 배열에서 찾기
const project = projects.find(p => p.id == projectId);
if (project) {
return `${project.job_no} / ${project.project_name}`;
}
return `<span class="text-red-500">프로젝트 ID: ${projectId} (정보 없음)</span>`;
}
// 날짜 범위 설정 및 자동 조회
function setDateRange(range) {
currentRange = range;
const today = new Date();
let startDate, endDate;
switch (range) {
case 'today':
startDate = new Date(today);
endDate = new Date(today);
break;
case 'week':
startDate = new Date(today);
startDate.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
endDate = new Date(today);
break;
case 'month':
startDate = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
endDate = new Date(today);
break;
case 'all':
startDate = new Date(2020, 0, 1); // 충분히 과거 날짜
endDate = new Date(today);
break;
default:
return;
}
// 날짜 입력 필드 업데이트
document.getElementById('startDateInput').value = formatDateForInput(startDate);
document.getElementById('endDateInput').value = formatDateForInput(endDate);
// 필터 적용
filterIssues();
}
// 부적합 사항 로드 (자신이 올린 내용만)
async function loadIssues() {
const container = document.getElementById('issueResults');
container.innerHTML = `
<div class="text-gray-500 text-center py-8">
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
<p>데이터를 불러오는 중...</p>
</div>
`;
try {
// 모든 이슈 가져오기
const allIssues = await IssuesAPI.getAll();
// 자신이 올린 이슈만 필터링
issues = allIssues
.filter(issue => issue.reporter_id === currentUser.id)
.sort((a, b) => new Date(b.report_date) - new Date(a.report_date));
// 결과 표시
filterIssues();
} catch (error) {
console.error('부적합 사항 로드 실패:', error);
container.innerHTML = `
<div class="text-red-500 text-center py-8">
<i class="fas fa-exclamation-triangle text-3xl mb-3"></i>
<p>데이터를 불러오는데 실패했습니다.</p>
<button onclick="loadIssues()" class="mt-3 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
다시 시도
</button>
</div>
`;
}
}
// 결과 표시 (시간순 나열)
function displayResults() {
const container = document.getElementById('issueResults');
// 필터링된 결과 사용 (filterIssues에서 설정됨)
const filteredIssues = window.filteredIssues || issues;
if (filteredIssues.length === 0) {
const emptyMessage = currentUser.role === 'admin'
? '조건에 맞는 부적합 사항이 없습니다.'
: '아직 등록한 부적합 사항이 없습니다.<br><small class="text-sm">부적합 등록 페이지에서 새로운 부적합을 등록해보세요.</small>';
container.innerHTML = `
<div class="text-gray-500 text-center py-12">
<i class="fas fa-inbox text-4xl mb-4 text-gray-400"></i>
<p class="text-lg mb-2">${emptyMessage}</p>
${currentUser.role !== 'admin' ? `
<div class="mt-4">
<a href="/index.html" class="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-plus mr-2"></i>
부적합 등록하기
</a>
</div>
` : ''}
</div>
`;
return;
}
// 워크플로우 상태별로 분류 및 정렬
const groupedIssues = {
pending_review: filteredIssues.filter(issue =>
issue.review_status === 'pending_review' || (!issue.review_status && !isReviewCompleted(issue))
),
in_progress: filteredIssues.filter(issue => issue.review_status === 'in_progress'),
completed: filteredIssues.filter(issue =>
issue.review_status === 'completed' || (!issue.review_status && isReviewCompleted(issue))
),
disposed: filteredIssues.filter(issue => issue.review_status === 'disposed')
};
container.innerHTML = '';
// 각 상태별로 표시
const statusConfig = [
{ key: 'pending_review', title: '수신함 (검토 대기)', icon: 'fas fa-inbox', color: 'text-orange-700' },
{ key: 'in_progress', title: '관리함 (진행 중)', icon: 'fas fa-cog', color: 'text-blue-700' },
{ key: 'completed', title: '관리함 (완료됨)', icon: 'fas fa-check-circle', color: 'text-green-700' },
{ key: 'disposed', title: '폐기함 (폐기됨)', icon: 'fas fa-trash', color: 'text-gray-700' }
];
statusConfig.forEach((config, index) => {
const issues = groupedIssues[config.key];
if (issues.length > 0) {
const header = document.createElement('div');
header.className = index > 0 ? 'mb-4 mt-8' : 'mb-4';
header.innerHTML = `
<h3 class="text-md font-semibold ${config.color} flex items-center">
<i class="${config.icon} mr-2"></i>${config.title} (${issues.length}건)
</h3>
`;
container.appendChild(header);
issues.forEach(issue => {
container.appendChild(createIssueCard(issue, config.key === 'completed'));
});
}
});
}
// 워크플로우 상태 표시 함수
function getWorkflowStatusBadge(issue) {
const status = issue.review_status || (isReviewCompleted(issue) ? 'completed' : 'pending_review');
const statusConfig = {
'pending_review': { text: '검토 대기', class: 'bg-orange-100 text-orange-700', icon: 'fas fa-inbox' },
'in_progress': { text: '진행 중', class: 'bg-blue-100 text-blue-700', icon: 'fas fa-cog' },
'completed': { text: '완료됨', class: 'bg-green-100 text-green-700', icon: 'fas fa-check-circle' },
'disposed': { text: '폐기됨', class: 'bg-gray-100 text-gray-700', icon: 'fas fa-trash' }
};
const config = statusConfig[status] || statusConfig['pending_review'];
return `<span class="px-2 py-1 rounded-full text-xs font-medium ${config.class}">
<i class="${config.icon} mr-1"></i>${config.text}
</span>`;
}
// 부적합 사항 카드 생성 함수 (조회용)
function createIssueCard(issue, isCompleted) {
const categoryNames = {
material_missing: '자재누락',
design_error: '설계미스',
incoming_defect: '입고자재 불량',
inspection_miss: '검사미스'
};
const categoryColors = {
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
design_error: 'bg-blue-100 text-blue-700 border-blue-300',
incoming_defect: 'bg-red-100 text-red-700 border-red-300',
inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300'
};
const div = document.createElement('div');
// 검토 완료 상태에 따른 스타일링
const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4';
const statusClasses = isCompleted
? 'bg-gray-100 opacity-75'
: 'bg-gray-50 hover:bg-gray-100';
const borderColor = categoryColors[issue.category]?.split(' ')[2] || 'border-gray-300';
div.className = `${baseClasses} ${statusClasses} ${borderColor}`;
const dateStr = DateUtils.formatKST(issue.report_date, true);
const relativeTime = DateUtils.getRelativeTime(issue.report_date);
const projectInfo = getProjectInfo(issue.project_id || issue.projectId);
// 수정/삭제 권한 확인 (본인이 등록한 부적합만)
const canEdit = issue.reporter_id === currentUser.id;
const canDelete = issue.reporter_id === currentUser.id || currentUser.role === 'admin';
div.innerHTML = `
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
<div class="flex justify-between items-start p-2 pb-0">
<div class="flex items-center gap-2">
${getWorkflowStatusBadge(issue)}
</div>
<div class="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
<i class="fas fa-folder-open mr-1"></i>${projectInfo}
</div>
</div>
<!-- 기존 내용 -->
<div class="flex gap-3 p-3 pt-1">
<!-- 사진들 -->
<div class="flex gap-1 flex-shrink-0 flex-wrap max-w-md">
${(() => {
const photos = [
issue.photo_path,
issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(p => p);
if (photos.length === 0) {
return `
<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
<i class="fas fa-image text-gray-400"></i>
</div>
`;
}
return photos.map(path => `
<img src="${path}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${path}')">
`).join('');
})()}
</div>
<!-- 내용 -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between mb-2">
<span class="px-2 py-1 rounded-full text-xs font-medium ${categoryColors[issue.category] || 'bg-gray-100 text-gray-700'}">
${categoryNames[issue.category] || issue.category}
</span>
${issue.work_hours ?
`<span class="text-sm text-green-600 font-medium">
<i class="fas fa-clock mr-1"></i>${issue.work_hours}시간
</span>` :
'<span class="text-sm text-gray-400">시간 미입력</span>'
}
</div>
<p class="text-gray-800 mb-2 line-clamp-2">${issue.description}</p>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-sm text-gray-500">
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span>
<span><i class="fas fa-calendar mr-1"></i>${dateStr}</span>
<span class="text-xs text-gray-400">${relativeTime}</span>
</div>
<!-- 수정/삭제 버튼 -->
${(canEdit || canDelete) ? `
<div class="flex gap-2">
${canEdit ? `
<button onclick='showEditModal(${JSON.stringify(issue).replace(/'/g, "&apos;")})' class="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
<i class="fas fa-edit mr-1"></i>수정
</button>
` : ''}
${canDelete ? `
<button onclick="confirmDelete(${issue.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div>
` : ''}
</div>
</div>
</div>
`;
return div;
}
// 관리 버튼 클릭 처리
function handleAdminClick() {
if (currentUser.role === 'admin') {
// 관리자: 사용자 관리 페이지로 이동
window.location.href = 'admin.html';
}
}
// 비밀번호 변경 모달 표시
function showPasswordChangeModal() {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
modal.innerHTML = `
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">비밀번호 변경</h3>
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form id="passwordChangeForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
<input type="password" id="currentPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
<input type="password" id="newPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required minlength="6">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
<input type="password" id="confirmPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
</div>
<div class="flex gap-2 pt-4">
<button type="button" onclick="this.closest('.fixed').remove()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
변경
</button>
</div>
</form>
</div>
`;
document.body.appendChild(modal);
// 폼 제출 이벤트 처리
document.getElementById('passwordChangeForm').addEventListener('submit', handlePasswordChange);
}
// 비밀번호 변경 처리
async function handlePasswordChange(e) {
e.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
// 새 비밀번호 확인
if (newPassword !== confirmPassword) {
alert('새 비밀번호가 일치하지 않습니다.');
return;
}
// 현재 비밀번호 확인 (localStorage 기반)
let users = JSON.parse(localStorage.getItem('work-report-users') || '[]');
// 기본 사용자가 없으면 생성
if (users.length === 0) {
users = [
{
username: 'hyungi',
full_name: '관리자',
password: 'djg3-jj34-X3Q3',
role: 'admin'
}
];
localStorage.setItem('work-report-users', JSON.stringify(users));
}
let user = users.find(u => u.username === currentUser.username);
// 사용자가 없으면 기본값으로 생성
if (!user) {
const username = currentUser.username;
user = {
username: username,
full_name: username === 'hyungi' ? '관리자' : username,
password: 'djg3-jj34-X3Q3',
role: username === 'hyungi' ? 'admin' : 'user'
};
users.push(user);
localStorage.setItem('work-report-users', JSON.stringify(users));
}
if (user.password !== currentPassword) {
alert('현재 비밀번호가 올바르지 않습니다.');
return;
}
try {
// 비밀번호 변경
user.password = newPassword;
localStorage.setItem('work-report-users', JSON.stringify(users));
// 현재 사용자 정보도 업데이트
currentUser.password = newPassword;
localStorage.setItem('currentUser', JSON.stringify(currentUser));
alert('비밀번호가 성공적으로 변경되었습니다.');
document.querySelector('.fixed').remove(); // 모달 닫기
} catch (error) {
alert('비밀번호 변경에 실패했습니다: ' + error.message);
}
}
// 로그아웃 함수
function logout() {
TokenManager.removeToken();
TokenManager.removeUser();
window.location.href = 'index.html';
}
// 수정 모달 표시
function showEditModal(issue) {
const categoryNames = {
material_missing: '자재누락',
design_error: '설계미스',
incoming_defect: '입고자재 불량',
inspection_miss: '검사미스'
};
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
modal.innerHTML = `
<div class="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">부적합 수정</h3>
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form id="editIssueForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
<select id="editCategory" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
<option value="design_error" ${issue.category === 'design_error' ? 'selected' : ''}>설계미스</option>
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
<option value="inspection_miss" ${issue.category === 'inspection_miss' ? 'selected' : ''}>검사미스</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
<select id="editProject" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
${projects.map(p => `
<option value="${p.id}" ${p.id === issue.project_id ? 'selected' : ''}>
${p.job_no} / ${p.project_name}
</option>
`).join('')}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">내용</label>
<textarea id="editDescription" class="w-full px-3 py-2 border border-gray-300 rounded-lg" rows="4" required>${issue.description || ''}</textarea>
</div>
<div class="flex gap-2 pt-4">
<button type="button" onclick="this.closest('.fixed').remove()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
수정
</button>
</div>
</form>
</div>
`;
document.body.appendChild(modal);
// 폼 제출 이벤트 처리
document.getElementById('editIssueForm').addEventListener('submit', async (e) => {
e.preventDefault();
const updateData = {
category: document.getElementById('editCategory').value,
description: document.getElementById('editDescription').value,
project_id: parseInt(document.getElementById('editProject').value)
};
try {
await IssuesAPI.update(issue.id, updateData);
alert('수정되었습니다.');
modal.remove();
// 목록 새로고침
await loadIssues();
} catch (error) {
console.error('수정 실패:', error);
alert('수정에 실패했습니다: ' + error.message);
}
});
}
// 삭제 확인 다이얼로그
function confirmDelete(issueId) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
modal.innerHTML = `
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
<div class="text-center mb-4">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
</div>
<h3 class="text-lg font-semibold mb-2">부적합 삭제</h3>
<p class="text-sm text-gray-600">
이 부적합 사항을 삭제하시겠습니까?<br>
삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다.
</p>
</div>
<div class="flex gap-2">
<button onclick="this.closest('.fixed').remove()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button onclick="handleDelete(${issueId})"
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
삭제
</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
// 삭제 처리
async function handleDelete(issueId) {
try {
await IssuesAPI.delete(issueId);
alert('삭제되었습니다.');
// 모달 닫기
const modal = document.querySelector('.fixed');
if (modal) modal.remove();
// 목록 새로고침
await loadIssues();
} catch (error) {
console.error('삭제 실패:', error);
alert('삭제에 실패했습니다: ' + error.message);
}
}
// API 스크립트 동적 로딩
const script = document.createElement('script');
script.src = '/static/js/api.js?v=20260213';
script.onload = function() {
console.log('API 스크립트 로드 완료 (issue-view.html)');
// API 로드 후 초기화 시작
initializeIssueView();
};
script.onerror = function() {
console.error('API 스크립트 로드 실패');
};
document.head.appendChild(script);

View File

@@ -0,0 +1,332 @@
/**
* issues-archive.js — 폐기함 페이지 스크립트
*/
let currentUser = null;
let issues = [];
let projects = [];
let filteredIssues = [];
// API 로드 후 초기화 함수
async function initializeArchive() {
const token = TokenManager.getToken();
if (!token) {
window.location.href = '/index.html';
return;
}
try {
const user = await AuthAPI.getCurrentUser();
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
// 공통 헤더 초기화
await window.commonHeader.init(user, 'issues_archive');
// 페이지 접근 권한 체크
setTimeout(() => {
if (!canAccessPage('issues_archive')) {
alert('폐기함 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
}
}, 500);
// 데이터 로드
await loadProjects();
await loadArchivedIssues();
} catch (error) {
console.error('인증 실패:', error);
TokenManager.removeToken();
TokenManager.removeUser();
window.location.href = '/index.html';
}
}
// 프로젝트 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
projects = await response.json();
updateProjectFilter();
}
} catch (error) {
console.error('프로젝트 로드 실패:', error);
}
}
// 보관된 부적합 로드
async function loadArchivedIssues() {
try {
let endpoint = '/api/issues/';
// 관리자인 경우 전체 부적합 조회 API 사용
if (currentUser.role === 'admin') {
endpoint = '/api/issues/admin/all';
}
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const allIssues = await response.json();
// 폐기된 부적합만 필터링 (폐기함 전용)
issues = allIssues.filter(issue =>
issue.review_status === 'disposed'
);
filterIssues();
updateStatistics();
renderCharts();
} else {
throw new Error('부적합 목록을 불러올 수 없습니다.');
}
} catch (error) {
console.error('부적합 로드 실패:', error);
alert('부적합 목록을 불러오는데 실패했습니다.');
}
}
// 필터링 및 표시
function filterIssues() {
const projectFilter = document.getElementById('projectFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
const periodFilter = document.getElementById('periodFilter').value;
const categoryFilter = document.getElementById('categoryFilter').value;
const searchInput = document.getElementById('searchInput').value.toLowerCase();
filteredIssues = issues.filter(issue => {
if (projectFilter && issue.project_id != projectFilter) return false;
if (statusFilter && issue.status !== statusFilter) return false;
if (categoryFilter && issue.category !== categoryFilter) return false;
// 기간 필터
if (periodFilter) {
const issueDate = new Date(issue.updated_at || issue.created_at);
const now = new Date();
switch (periodFilter) {
case 'week':
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
if (issueDate < weekAgo) return false;
break;
case 'month':
const monthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
if (issueDate < monthAgo) return false;
break;
case 'quarter':
const quarterAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
if (issueDate < quarterAgo) return false;
break;
case 'year':
const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
if (issueDate < yearAgo) return false;
break;
}
}
if (searchInput) {
const searchText = `${issue.description} ${issue.reporter?.username || ''}`.toLowerCase();
if (!searchText.includes(searchInput)) return false;
}
return true;
});
sortIssues();
displayIssues();
}
function sortIssues() {
const sortOrder = document.getElementById('sortOrder').value;
filteredIssues.sort((a, b) => {
switch (sortOrder) {
case 'newest':
return new Date(b.report_date) - new Date(a.report_date);
case 'oldest':
return new Date(a.report_date) - new Date(b.report_date);
case 'completed':
return new Date(b.disposed_at || b.report_date) - new Date(a.disposed_at || a.report_date);
case 'category':
return (a.category || '').localeCompare(b.category || '');
default:
return new Date(b.report_date) - new Date(a.report_date);
}
});
}
function displayIssues() {
const container = document.getElementById('issuesList');
const emptyState = document.getElementById('emptyState');
if (filteredIssues.length === 0) {
container.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
container.innerHTML = filteredIssues.map(issue => {
const project = projects.find(p => p.id === issue.project_id);
// 폐기함은 폐기된 것만 표시
const completedDate = issue.disposed_at ? new Date(issue.disposed_at).toLocaleDateString('ko-KR') : 'Invalid Date';
const statusText = '폐기';
const cardClass = 'archived-card';
return `
<div class="issue-card p-6 ${cardClass} cursor-pointer"
onclick="viewArchivedIssue(${issue.id})">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<span class="badge badge-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</span>
${project ? `<span class="text-sm text-gray-500">${project.project_name}</span>` : ''}
<span class="text-sm text-gray-400">${completedDate}</span>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">${issue.description}</h3>
<div class="flex items-center text-sm text-gray-500 space-x-4">
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span>
${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''}
<span><i class="fas fa-clock mr-1"></i>${statusText}: ${completedDate}</span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<i class="fas fa-${getStatusIcon(issue.status)} text-2xl ${getStatusColor(issue.status)}"></i>
</div>
</div>
</div>
`;
}).join('');
}
// 통계 업데이트
function updateStatistics() {
const completed = issues.filter(issue => issue.status === 'completed').length;
const archived = issues.filter(issue => issue.status === 'archived').length;
const cancelled = issues.filter(issue => issue.status === 'cancelled').length;
const thisMonth = issues.filter(issue => {
const issueDate = new Date(issue.updated_at || issue.created_at);
const now = new Date();
return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear();
}).length;
document.getElementById('completedCount').textContent = completed;
document.getElementById('archivedCount').textContent = archived;
document.getElementById('cancelledCount').textContent = cancelled;
document.getElementById('thisMonthCount').textContent = thisMonth;
}
// 차트 렌더링 (간단한 텍스트 기반)
function renderCharts() {
renderMonthlyChart();
renderCategoryChart();
}
function renderMonthlyChart() {
const canvas = document.getElementById('monthlyChart');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
ctx.fillStyle = '#374151';
ctx.font = '16px Inter';
ctx.textAlign = 'center';
ctx.fillText('월별 완료 현황 차트', canvas.width / 2, canvas.height / 2);
ctx.font = '12px Inter';
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
}
function renderCategoryChart() {
const canvas = document.getElementById('categoryChart');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
ctx.fillStyle = '#374151';
ctx.font = '16px Inter';
ctx.textAlign = 'center';
ctx.fillText('카테고리별 분포 차트', canvas.width / 2, canvas.height / 2);
ctx.font = '12px Inter';
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
}
// 기타 함수들
function generateReport() {
alert('통계 보고서를 생성합니다.');
}
function cleanupArchive() {
if (confirm('오래된 보관 데이터를 정리하시겠습니까?')) {
alert('데이터 정리가 완료되었습니다.');
}
}
function viewArchivedIssue(issueId) {
window.location.href = `/issue-view.html#detail-${issueId}`;
}
// 유틸리티 함수들
function updateProjectFilter() {
const projectFilter = document.getElementById('projectFilter');
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name;
projectFilter.appendChild(option);
});
}
// 페이지 전용 유틸리티 (shared에 없는 것들)
function getStatusIcon(status) {
const iconMap = {
'completed': 'check-circle',
'archived': 'archive',
'cancelled': 'times-circle'
};
return iconMap[status] || 'archive';
}
function getStatusColor(status) {
const colorMap = {
'completed': 'text-green-500',
'archived': 'text-gray-500',
'cancelled': 'text-red-500'
};
return colorMap[status] || 'text-gray-500';
}
// API 스크립트 동적 로딩
const script = document.createElement('script');
script.src = '/static/js/api.js?v=20260213';
script.onload = function() {
console.log('API 스크립트 로드 완료 (issues-archive.html)');
initializeArchive();
};
script.onerror = function() {
console.error('API 스크립트 로드 실패');
};
document.head.appendChild(script);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,888 @@
/**
* issues-inbox.js — 수신함 페이지 스크립트
*/
let currentUser = null;
let issues = [];
let projects = [];
let filteredIssues = [];
// 한국 시간(KST) 유틸리티 함수
function getKSTDate(date) {
const utcDate = new Date(date);
// UTC + 9시간 = KST
return new Date(utcDate.getTime() + (9 * 60 * 60 * 1000));
}
function formatKSTDate(date) {
const kstDate = getKSTDate(date);
return kstDate.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
}
function formatKSTTime(date) {
const kstDate = getKSTDate(date);
return kstDate.toLocaleTimeString('ko-KR', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit'
});
}
function getKSTToday() {
const now = new Date();
const kstNow = getKSTDate(now);
return new Date(kstNow.getFullYear(), kstNow.getMonth(), kstNow.getDate());
}
// 애니메이션 함수들
function animateHeaderAppearance() {
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
if (headerElement) {
headerElement.classList.add('header-fade-in');
setTimeout(() => {
headerElement.classList.add('visible');
// 헤더 애니메이션 완료 후 본문 애니메이션
setTimeout(() => {
animateContentAppearance();
}, 200);
}, 50);
} else {
// 헤더를 찾지 못했으면 바로 본문 애니메이션
animateContentAppearance();
}
}
// 본문 컨텐츠 애니메이션
function animateContentAppearance() {
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
const contentElements = document.querySelectorAll('.content-fade-in');
contentElements.forEach((element, index) => {
setTimeout(() => {
element.classList.add('visible');
}, index * 100); // 100ms씩 지연
});
}
// API 로드 후 초기화 함수
async function initializeInbox() {
console.log('수신함 초기화 시작');
const token = TokenManager.getToken();
if (!token) {
window.location.href = '/index.html';
return;
}
try {
const user = await AuthAPI.getCurrentUser();
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
// 공통 헤더 초기화
await window.commonHeader.init(user, 'issues_inbox');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
// 페이지 접근 권한 체크
setTimeout(() => {
if (typeof canAccessPage === 'function') {
const hasAccess = canAccessPage('issues_inbox');
if (!hasAccess) {
alert('수신함 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
}
}
}, 500);
// 데이터 로드
await loadProjects();
await loadIssues();
// loadIssues()에서 이미 loadStatistics() 호출함
} catch (error) {
console.error('수신함 초기화 실패:', error);
// 401 Unauthorized 에러인 경우만 로그아웃 처리
if (error.message && (error.message.includes('401') || error.message.includes('Unauthorized') || error.message.includes('Not authenticated'))) {
TokenManager.removeToken();
TokenManager.removeUser();
window.location.href = '/index.html';
} else {
// 다른 에러는 사용자에게 알리고 계속 진행
alert('일부 데이터를 불러오는데 실패했습니다. 새로고침 후 다시 시도해주세요.');
// 공통 헤더만이라도 초기화
try {
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (user.id) {
await window.commonHeader.init(user, 'issues_inbox');
// 에러 상황에서도 애니메이션 적용
setTimeout(() => {
animateHeaderAppearance();
}, 100);
}
} catch (headerError) {
console.error('공통 헤더 초기화 실패:', headerError);
}
}
}
}
// 프로젝트 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
projects = await response.json();
updateProjectFilter();
}
} catch (error) {
console.error('프로젝트 로드 실패:', error);
}
}
// 프로젝트 필터 업데이트
function updateProjectFilter() {
const projectFilter = document.getElementById('projectFilter');
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name;
projectFilter.appendChild(option);
});
}
// 수신함 부적합 목록 로드 (실제 API 연동)
async function loadIssues() {
showLoading(true);
try {
const projectId = document.getElementById('projectFilter').value;
let url = '/api/inbox/';
// 프로젝트 필터 적용
if (projectId) {
url += `?project_id=${projectId}`;
}
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
issues = await response.json();
filterIssues();
await loadStatistics();
} else {
throw new Error('수신함 목록을 불러올 수 없습니다.');
}
} catch (error) {
console.error('수신함 로드 실패:', error);
showError('수신함 목록을 불러오는데 실패했습니다.');
} finally {
showLoading(false);
}
}
// 신고 필터링
function filterIssues() {
const projectFilter = document.getElementById('projectFilter').value;
filteredIssues = issues.filter(issue => {
// 프로젝트 필터
if (projectFilter && issue.project_id != projectFilter) return false;
return true;
});
sortIssues();
displayIssues();
}
// 신고 정렬
function sortIssues() {
const sortOrder = document.getElementById('sortOrder').value;
filteredIssues.sort((a, b) => {
switch (sortOrder) {
case 'newest':
return new Date(b.report_date) - new Date(a.report_date);
case 'oldest':
return new Date(a.report_date) - new Date(b.report_date);
case 'priority':
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
return (priorityOrder[b.priority] || 1) - (priorityOrder[a.priority] || 1);
default:
return new Date(b.report_date) - new Date(a.report_date);
}
});
}
// 부적합 목록 표시
function displayIssues() {
const container = document.getElementById('issuesList');
const emptyState = document.getElementById('emptyState');
if (filteredIssues.length === 0) {
container.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
container.innerHTML = filteredIssues.map(issue => {
const project = projects.find(p => p.id === issue.project_id);
const reportDate = new Date(issue.report_date);
const createdDate = formatKSTDate(reportDate);
const createdTime = formatKSTTime(reportDate);
const timeAgo = getTimeAgo(reportDate);
// 사진 정보 처리
const photoCount = [issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean).length;
const photoInfo = photoCount > 0 ? `사진 ${photoCount}` : '사진 없음';
return `
<div class="issue-card p-6 hover:bg-gray-50 border-l-4 border-blue-500"
data-issue-id="${issue.id}">
<div class="flex items-start justify-between">
<div class="flex-1">
<!-- 상단 정보 -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<span class="badge badge-new">검토 대기</span>
${project ? `<span class="text-sm font-medium text-blue-600">${project.project_name}</span>` : '<span class="text-sm text-gray-400">프로젝트 미지정</span>'}
</div>
<span class="text-xs text-gray-400">ID: ${issue.id}</span>
</div>
<!-- 제목 -->
<h3 class="text-lg font-semibold text-gray-900 mb-3 cursor-pointer hover:text-blue-600 transition-colors" onclick="viewIssueDetail(${issue.id})">${issue.final_description || issue.description}</h3>
<!-- 상세 정보 그리드 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4 text-sm">
<div class="flex items-center text-gray-600">
<i class="fas fa-user mr-2 text-blue-500"></i>
<span class="font-medium">${issue.reporter?.username || '알 수 없음'}</span>
</div>
<div class="flex items-center text-gray-600">
<i class="fas fa-tag mr-2 text-green-500"></i>
<span>${getCategoryText(issue.category || issue.final_category)}</span>
</div>
<div class="flex items-center text-gray-600">
<i class="fas fa-camera mr-2 text-purple-500"></i>
<span class="${photoCount > 0 ? 'text-purple-600 font-medium' : ''}">${photoInfo}</span>
</div>
<div class="flex items-center text-gray-600">
<i class="fas fa-clock mr-2 text-orange-500"></i>
<span class="font-medium">${timeAgo}</span>
</div>
</div>
<!-- 업로드 시간 정보 -->
<div class="bg-gray-50 rounded-lg p-3 mb-4">
<div class="flex items-center justify-between text-sm">
<div class="flex items-center text-gray-600">
<i class="fas fa-calendar-alt mr-2"></i>
<span>업로드: <strong>${createdDate} ${createdTime}</strong></span>
</div>
${issue.work_hours > 0 ? `<div class="flex items-center text-gray-600">
<i class="fas fa-hourglass-half mr-2"></i>
<span>공수: <strong>${issue.work_hours}시간</strong></span>
</div>` : ''}
</div>
${issue.detail_notes ? `<div class="mt-2 text-sm text-gray-600">
<i class="fas fa-sticky-note mr-2"></i>
<span class="italic">"${issue.detail_notes}"</span>
</div>` : ''}
</div>
<!-- 사진 미리보기 -->
${photoCount > 0 ? `
<div class="photo-gallery">
${[issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5]
.filter(Boolean)
.map((path, idx) => `<img src="${path}" class="photo-preview" onclick="openPhotoModal('${path}')" alt="첨부 사진 ${idx + 1}">`)
.join('')}
</div>
` : ''}
<!-- 워크플로우 액션 버튼들 -->
<div class="flex items-center space-x-2 mt-3">
<button onclick="openDisposeModal(${issue.id})"
class="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-1"></i>폐기
</button>
<button onclick="openReviewModal(${issue.id})"
class="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600 transition-colors">
<i class="fas fa-edit mr-1"></i>검토
</button>
<button onclick="openStatusModal(${issue.id})"
class="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 transition-colors">
<i class="fas fa-check mr-1"></i>확인
</button>
</div>
</div>
</div>
</div>
`;
}).join('');
}
// 통계 로드 (새로운 기준)
async function loadStatistics() {
try {
// 현재 수신함 이슈들을 기반으로 통계 계산 (KST 기준)
const todayStart = getKSTToday();
// 금일 신규: 오늘 올라온 목록 숫자 (확인된 것 포함) - KST 기준
const todayNewCount = issues.filter(issue => {
const reportDate = getKSTDate(new Date(issue.report_date));
const reportDateOnly = new Date(reportDate.getFullYear(), reportDate.getMonth(), reportDate.getDate());
return reportDateOnly >= todayStart;
}).length;
// 금일 처리: 오늘 처리된 건수 (API에서 가져와야 함)
let todayProcessedCount = 0;
try {
const processedResponse = await fetch('/api/inbox/statistics', {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
}
});
if (processedResponse.ok) {
const stats = await processedResponse.json();
todayProcessedCount = stats.today_processed || 0;
}
} catch (e) {
console.log('처리된 건수 조회 실패:', e);
}
// 미해결: 오늘꺼 제외한 남아있는 것들 - KST 기준
const unresolvedCount = issues.filter(issue => {
const reportDate = getKSTDate(new Date(issue.report_date));
const reportDateOnly = new Date(reportDate.getFullYear(), reportDate.getMonth(), reportDate.getDate());
return reportDateOnly < todayStart;
}).length;
// 통계 업데이트
document.getElementById('todayNewCount').textContent = todayNewCount;
document.getElementById('todayProcessedCount').textContent = todayProcessedCount;
document.getElementById('unresolvedCount').textContent = unresolvedCount;
} catch (error) {
console.error('통계 로드 오류:', error);
// 오류 시 기본값 설정
document.getElementById('todayNewCount').textContent = '0';
document.getElementById('todayProcessedCount').textContent = '0';
document.getElementById('unresolvedCount').textContent = '0';
}
}
// 새로고침
function refreshInbox() {
loadIssues();
}
// 신고 상세 보기
function viewIssueDetail(issueId) {
window.location.href = `/issue-view.html#detail-${issueId}`;
}
// openPhotoModal, closePhotoModal, handleEscKey는 photo-modal.js에서 제공됨
// ===== 워크플로우 모달 관련 함수들 =====
let currentIssueId = null;
// 폐기 모달 열기
function openDisposeModal(issueId) {
currentIssueId = issueId;
document.getElementById('disposalReason').value = 'duplicate';
document.getElementById('customReason').value = '';
document.getElementById('customReasonDiv').classList.add('hidden');
document.getElementById('selectedDuplicateId').value = '';
document.getElementById('disposeModal').classList.remove('hidden');
// 중복 선택 영역 표시 (기본값이 duplicate이므로)
toggleDuplicateSelection();
}
// 폐기 모달 닫기
function closeDisposeModal() {
currentIssueId = null;
document.getElementById('disposeModal').classList.add('hidden');
}
// 사용자 정의 사유 토글
function toggleCustomReason() {
const reason = document.getElementById('disposalReason').value;
const customDiv = document.getElementById('customReasonDiv');
if (reason === 'custom') {
customDiv.classList.remove('hidden');
} else {
customDiv.classList.add('hidden');
}
}
// 중복 대상 선택 토글
function toggleDuplicateSelection() {
const reason = document.getElementById('disposalReason').value;
const duplicateDiv = document.getElementById('duplicateSelectionDiv');
if (reason === 'duplicate') {
duplicateDiv.classList.remove('hidden');
loadManagementIssues();
} else {
duplicateDiv.classList.add('hidden');
document.getElementById('selectedDuplicateId').value = '';
}
}
// 관리함 이슈 목록 로드
async function loadManagementIssues() {
const currentIssue = issues.find(issue => issue.id === currentIssueId);
const projectId = currentIssue ? currentIssue.project_id : null;
try {
const response = await fetch(`/api/inbox/management-issues${projectId ? `?project_id=${projectId}` : ''}`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`
}
});
if (!response.ok) {
throw new Error('관리함 이슈 목록을 불러올 수 없습니다.');
}
const managementIssues = await response.json();
displayManagementIssues(managementIssues);
} catch (error) {
console.error('관리함 이슈 로드 오류:', error);
document.getElementById('managementIssuesList').innerHTML = `
<div class="p-4 text-center text-red-500">
<i class="fas fa-exclamation-triangle mr-2"></i>이슈 목록을 불러올 수 없습니다.
</div>
`;
}
}
// 관리함 이슈 목록 표시
function displayManagementIssues(managementIssues) {
const container = document.getElementById('managementIssuesList');
if (managementIssues.length === 0) {
container.innerHTML = `
<div class="p-4 text-center text-gray-500">
<i class="fas fa-inbox mr-2"></i>동일 프로젝트의 관리함 이슈가 없습니다.
</div>
`;
return;
}
container.innerHTML = managementIssues.map(issue => `
<div class="p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer"
onclick="selectDuplicateTarget(${issue.id}, this)">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="text-sm font-medium text-gray-900 mb-1">
${issue.description || issue.final_description}
</div>
<div class="flex items-center gap-2 text-xs text-gray-500">
<span class="px-2 py-1 bg-gray-100 rounded">${getCategoryText(issue.category || issue.final_category)}</span>
<span>신고자: ${issue.reporter_name}</span>
${issue.duplicate_count > 0 ? `<span class="text-orange-600">중복 ${issue.duplicate_count}건</span>` : ''}
</div>
</div>
<div class="text-xs text-gray-400">
ID: ${issue.id}
</div>
</div>
</div>
`).join('');
}
// 중복 대상 선택
function selectDuplicateTarget(issueId, element) {
// 이전 선택 해제
document.querySelectorAll('#managementIssuesList > div').forEach(div => {
div.classList.remove('bg-blue-50', 'border-blue-200');
});
// 현재 선택 표시
element.classList.add('bg-blue-50', 'border-blue-200');
document.getElementById('selectedDuplicateId').value = issueId;
}
// 폐기 확인
async function confirmDispose() {
if (!currentIssueId) return;
const disposalReason = document.getElementById('disposalReason').value;
const customReason = document.getElementById('customReason').value;
const duplicateId = document.getElementById('selectedDuplicateId').value;
// 사용자 정의 사유 검증
if (disposalReason === 'custom' && !customReason.trim()) {
alert('사용자 정의 폐기 사유를 입력해주세요.');
return;
}
// 중복 대상 선택 검증
if (disposalReason === 'duplicate' && !duplicateId) {
alert('중복 대상을 선택해주세요.');
return;
}
try {
const requestBody = {
disposal_reason: disposalReason,
custom_disposal_reason: disposalReason === 'custom' ? customReason : null
};
// 중복 처리인 경우 대상 ID 추가
if (disposalReason === 'duplicate' && duplicateId) {
requestBody.duplicate_of_issue_id = parseInt(duplicateId);
}
const response = await fetch(`/api/inbox/${currentIssueId}/dispose`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (response.ok) {
const result = await response.json();
const message = disposalReason === 'duplicate'
? '중복 신고가 처리되었습니다.\n신고자 정보가 원본 이슈에 추가되었습니다.'
: `부적합이 성공적으로 폐기되었습니다.\n사유: ${getDisposalReasonText(disposalReason)}`;
alert(message);
closeDisposeModal();
await loadIssues(); // 목록 새로고침
} else {
const error = await response.json();
throw new Error(error.detail || '폐기 처리에 실패했습니다.');
}
} catch (error) {
console.error('폐기 처리 오류:', error);
alert('폐기 처리 중 오류가 발생했습니다: ' + error.message);
}
}
// 검토 모달 열기
async function openReviewModal(issueId) {
currentIssueId = issueId;
// 현재 부적합 정보 찾기
const issue = issues.find(i => i.id === issueId);
if (!issue) return;
// 원본 정보 표시
const originalInfo = document.getElementById('originalInfo');
const project = projects.find(p => p.id === issue.project_id);
originalInfo.innerHTML = `
<div class="space-y-2">
<div><strong>프로젝트:</strong> ${project ? project.project_name : '미지정'}</div>
<div><strong>카테고리:</strong> ${getCategoryText(issue.category || issue.final_category)}</div>
<div><strong>설명:</strong> ${issue.description || issue.final_description}</div>
<div><strong>등록자:</strong> ${issue.reporter?.username || '알 수 없음'}</div>
<div><strong>등록일:</strong> ${new Date(issue.report_date).toLocaleDateString('ko-KR')}</div>
</div>
`;
// 프로젝트 옵션 업데이트
const reviewProjectSelect = document.getElementById('reviewProjectId');
reviewProjectSelect.innerHTML = '<option value="">프로젝트 선택</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name;
if (project.id === issue.project_id) {
option.selected = true;
}
reviewProjectSelect.appendChild(option);
});
// 현재 값들로 폼 초기화 (최신 내용 우선 사용)
document.getElementById('reviewCategory').value = issue.category || issue.final_category;
// 최신 description을 title과 description으로 분리 (첫 번째 줄을 title로 사용)
const currentDescription = issue.description || issue.final_description;
const lines = currentDescription.split('\n');
document.getElementById('reviewTitle').value = lines[0] || '';
document.getElementById('reviewDescription').value = lines.slice(1).join('\n') || currentDescription;
document.getElementById('reviewModal').classList.remove('hidden');
}
// 검토 모달 닫기
function closeReviewModal() {
currentIssueId = null;
document.getElementById('reviewModal').classList.add('hidden');
}
// 검토 저장
async function saveReview() {
if (!currentIssueId) return;
const projectId = document.getElementById('reviewProjectId').value;
const category = document.getElementById('reviewCategory').value;
const title = document.getElementById('reviewTitle').value.trim();
const description = document.getElementById('reviewDescription').value.trim();
if (!title) {
alert('부적합명을 입력해주세요.');
return;
}
// 부적합명과 상세 내용을 합쳐서 저장 (첫 번째 줄에 제목, 나머지는 상세 내용)
const combinedDescription = title + (description ? '\n' + description : '');
try {
const response = await fetch(`/api/inbox/${currentIssueId}/review`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
project_id: projectId ? parseInt(projectId) : null,
category: category,
description: combinedDescription
})
});
if (response.ok) {
const result = await response.json();
alert(`검토가 완료되었습니다.\n수정된 항목: ${result.modifications_count}`);
closeReviewModal();
await loadIssues(); // 목록 새로고침
} else {
const error = await response.json();
throw new Error(error.detail || '검토 처리에 실패했습니다.');
}
} catch (error) {
console.error('검토 처리 오류:', error);
alert('검토 처리 중 오류가 발생했습니다: ' + error.message);
}
}
// 상태 모달 열기
function openStatusModal(issueId) {
currentIssueId = issueId;
// 라디오 버튼 초기화
document.querySelectorAll('input[name="finalStatus"]').forEach(radio => {
radio.checked = false;
});
document.getElementById('statusModal').classList.remove('hidden');
}
// 상태 모달 닫기
function closeStatusModal() {
currentIssueId = null;
document.getElementById('statusModal').classList.add('hidden');
// 완료 관련 필드 초기화
document.getElementById('completionSection').classList.add('hidden');
document.getElementById('completionPhotoInput').value = '';
document.getElementById('completionPhotoPreview').classList.add('hidden');
document.getElementById('solutionInput').value = '';
document.getElementById('responsibleDepartmentInput').value = '';
document.getElementById('responsiblePersonInput').value = '';
completionPhotoBase64 = null;
}
// 완료 섹션 토글
function toggleCompletionPhotoSection() {
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
const completionSection = document.getElementById('completionSection');
if (selectedStatus && selectedStatus.value === 'completed') {
completionSection.classList.remove('hidden');
} else {
completionSection.classList.add('hidden');
// 완료 관련 필드 초기화
document.getElementById('completionPhotoInput').value = '';
document.getElementById('completionPhotoPreview').classList.add('hidden');
document.getElementById('solutionInput').value = '';
document.getElementById('responsibleDepartmentInput').value = '';
document.getElementById('responsiblePersonInput').value = '';
completionPhotoBase64 = null;
}
}
// 완료 사진 선택 처리
let completionPhotoBase64 = null;
function handleCompletionPhotoSelect(event) {
const file = event.target.files[0];
if (!file) {
completionPhotoBase64 = null;
document.getElementById('completionPhotoPreview').classList.add('hidden');
return;
}
// 파일 크기 체크 (5MB 제한)
if (file.size > 5 * 1024 * 1024) {
alert('파일 크기는 5MB 이하여야 합니다.');
event.target.value = '';
return;
}
// 이미지 파일인지 확인
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 가능합니다.');
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(e) {
completionPhotoBase64 = e.target.result.split(',')[1]; // Base64 부분만 추출
// 미리보기 표시
document.getElementById('completionPhotoImg').src = e.target.result;
document.getElementById('completionPhotoPreview').classList.remove('hidden');
};
reader.readAsDataURL(file);
}
// 상태 변경 확인
async function confirmStatus() {
if (!currentIssueId) return;
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
if (!selectedStatus) {
alert('상태를 선택해주세요.');
return;
}
const reviewStatus = selectedStatus.value;
try {
const requestBody = {
review_status: reviewStatus
};
// 완료 상태일 때 추가 정보 수집
if (reviewStatus === 'completed') {
// 완료 사진
if (completionPhotoBase64) {
requestBody.completion_photo = completionPhotoBase64;
}
// 해결방안
const solution = document.getElementById('solutionInput').value.trim();
if (solution) {
requestBody.solution = solution;
}
// 담당부서
const responsibleDepartment = document.getElementById('responsibleDepartmentInput').value;
if (responsibleDepartment) {
requestBody.responsible_department = responsibleDepartment;
}
// 담당자
const responsiblePerson = document.getElementById('responsiblePersonInput').value.trim();
if (responsiblePerson) {
requestBody.responsible_person = responsiblePerson;
}
}
const response = await fetch(`/api/inbox/${currentIssueId}/status`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (response.ok) {
const result = await response.json();
alert(`상태가 성공적으로 변경되었습니다.\n${result.destination}으로 이동됩니다.`);
closeStatusModal();
await loadIssues(); // 목록 새로고침
} else {
const error = await response.json();
throw new Error(error.detail || '상태 변경에 실패했습니다.');
}
} catch (error) {
console.error('상태 변경 오류:', error);
alert('상태 변경 중 오류가 발생했습니다: ' + error.message);
}
}
// getStatusBadgeClass, getStatusText, getCategoryText, getDisposalReasonText는
// issue-helpers.js에서 제공됨
function getTimeAgo(date) {
const now = getKSTDate(new Date());
const kstDate = getKSTDate(date);
const diffMs = now - kstDate;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return formatKSTDate(date);
}
function showLoading(show) {
const overlay = document.getElementById('loadingOverlay');
if (show) {
overlay.classList.add('active');
} else {
overlay.classList.remove('active');
}
}
function showError(message) {
alert(message);
}
// API 스크립트 동적 로딩
const script = document.createElement('script');
script.src = '/static/js/api.js?v=20260213';
script.onload = function() {
console.log('API 스크립트 로드 완료 (issues-inbox.html)');
initializeInbox();
};
script.onerror = function() {
console.error('API 스크립트 로드 실패');
};
document.head.appendChild(script);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
/**
* issue-helpers.js — 부적합 관리 공통 유틸리티 함수
* dashboard, management, inbox, archive 등에서 공유
*/
function getDepartmentText(department) {
const departments = {
'production': '생산',
'quality': '품질',
'purchasing': '구매',
'design': '설계',
'sales': '영업'
};
return department ? departments[department] || department : '-';
}
function getCategoryText(category) {
const categoryMap = {
'material_missing': '자재 누락',
'design_error': '설계 오류',
'incoming_defect': '반입 불량',
'inspection_miss': '검사 누락',
'quality': '품질',
'safety': '안전',
'environment': '환경',
'process': '공정',
'equipment': '장비',
'material': '자재',
'etc': '기타'
};
return categoryMap[category] || category || '-';
}
function getStatusBadgeClass(status) {
const statusMap = {
'new': 'new',
'processing': 'processing',
'pending': 'pending',
'completed': 'completed',
'archived': 'archived',
'cancelled': 'cancelled'
};
return statusMap[status] || 'new';
}
function getStatusText(status) {
const statusMap = {
'new': '새 부적합',
'processing': '처리 중',
'pending': '대기 중',
'completed': '완료',
'archived': '보관',
'cancelled': '취소'
};
return statusMap[status] || status;
}
function getIssueTitle(issue) {
const description = issue.description || issue.final_description || '';
const lines = description.split('\n');
return lines[0] || '부적합명 없음';
}
function getIssueDetail(issue) {
const description = issue.description || issue.final_description || '';
const lines = description.split('\n');
return lines.slice(1).join('\n') || '상세 내용 없음';
}
function getDisposalReasonText(reason) {
const reasonMap = {
'duplicate': '중복',
'invalid_report': '잘못된 신고',
'not_applicable': '해당 없음',
'spam': '스팸/오류',
'custom': '직접 입력'
};
return reasonMap[reason] || reason;
}
function getReporterNames(issue) {
let names = [issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'];
if (issue.duplicate_reporters && issue.duplicate_reporters.length > 0) {
const duplicateNames = issue.duplicate_reporters.map(r => r.full_name || r.username);
names = names.concat(duplicateNames);
}
return names.join(', ');
}

View File

@@ -0,0 +1,42 @@
/**
* photo-modal.js — 사진 확대 모달 공통 모듈
* dashboard, management, inbox, issue-view 등에서 공유
*/
function openPhotoModal(photoPath) {
if (!photoPath) return;
const modal = document.createElement('div');
modal.className = 'photo-modal-overlay';
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
modal.innerHTML = `
<div class="photo-modal-content">
<img src="${photoPath}" alt="확대된 사진">
<button class="photo-modal-close" onclick="this.closest('.photo-modal-overlay').remove()">
<i class="fas fa-times"></i>
</button>
</div>
`;
document.body.appendChild(modal);
// ESC 키로 닫기
const handleEsc = (e) => {
if (e.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', handleEsc);
}
};
document.addEventListener('keydown', handleEsc);
}
// 기존 코드 호환용 별칭
function showImageModal(imagePath) {
openPhotoModal(imagePath);
}
function closePhotoModal() {
const modal = document.querySelector('.photo-modal-overlay');
if (modal) modal.remove();
}

View File

@@ -0,0 +1,45 @@
/**
* toast.js — 토스트 알림 공통 모듈
*/
function showToast(message, type = 'success', duration = 3000) {
const existing = document.querySelector('.toast-notification');
if (existing) existing.remove();
const iconMap = {
success: 'fas fa-check-circle',
error: 'fas fa-exclamation-circle',
warning: 'fas fa-exclamation-triangle',
info: 'fas fa-info-circle'
};
const colorMap = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const toast = document.createElement('div');
toast.className = `toast-notification fixed top-4 right-4 z-[9999] ${colorMap[type] || colorMap.info} text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-2 transform translate-x-full transition-transform duration-300`;
toast.innerHTML = `
<i class="${iconMap[type] || iconMap.info}"></i>
<span>${message}</span>
`;
document.body.appendChild(toast);
requestAnimationFrame(() => {
toast.style.transform = 'translateX(0)';
});
setTimeout(() => {
toast.style.transform = 'translateX(120%)';
setTimeout(() => toast.remove(), 300);
}, duration);
}
// 기존 코드 호환용 별칭
function showToastMessage(message, type = 'success') {
showToast(message, type);
}