diff --git a/system3-nonconformance/web/issue-view.html b/system3-nonconformance/web/issue-view.html index 0111aa8..b4bebbf 100644 --- a/system3-nonconformance/web/issue-view.html +++ b/system3-nonconformance/web/issue-view.html @@ -4,55 +4,19 @@ 부적합 사항 조회 - 작업보고서 - + + - + - - + - - + + @@ -70,7 +34,7 @@ 내가 등록한 부적합 사항을 확인할 수 있습니다

- +
@@ -83,7 +47,7 @@
- +
@@ -100,7 +64,7 @@
- +
@@ -108,7 +72,7 @@
- +
@@ -125,7 +89,7 @@
- +
@@ -142,929 +106,14 @@ - - - - - + + + + + + + + + diff --git a/system3-nonconformance/web/issues-archive.html b/system3-nonconformance/web/issues-archive.html index eaf9e51..2e079ce 100644 --- a/system3-nonconformance/web/issues-archive.html +++ b/system3-nonconformance/web/issues-archive.html @@ -4,36 +4,22 @@ 폐기함 - 작업보고서 - + + - + - + - + - - + + @@ -61,7 +47,7 @@ - +
@@ -113,7 +99,7 @@
- +
@@ -124,7 +110,7 @@
- +
@@ -136,7 +122,7 @@
- +
@@ -149,11 +135,11 @@
- +
-
@@ -169,7 +155,7 @@
- +

카테고리별 분포

@@ -195,11 +181,11 @@
- +
- + - - - - - - - - - - - - +
@@ -207,11 +113,11 @@
- +
- + - - - - + @@ -201,31 +48,31 @@ - +
- -
- + -
- +
@@ -281,11 +128,11 @@
- +
- + - +
- +
- +
@@ -347,13 +194,13 @@
- +
-
- +
- - - - - - - + + + + + + + + + + diff --git a/system3-nonconformance/web/nginx.conf b/system3-nonconformance/web/nginx.conf index a8a239f..b0fae54 100644 --- a/system3-nonconformance/web/nginx.conf +++ b/system3-nonconformance/web/nginx.conf @@ -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"; } # 정적 파일 (이미지 등) diff --git a/system3-nonconformance/web/static/css/issue-view.css b/system3-nonconformance/web/static/css/issue-view.css new file mode 100644 index 0000000..17817d4 --- /dev/null +++ b/system3-nonconformance/web/static/css/issue-view.css @@ -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; +} diff --git a/system3-nonconformance/web/static/css/issues-archive.css b/system3-nonconformance/web/static/css/issues-archive.css new file mode 100644 index 0000000..8fe63b0 --- /dev/null +++ b/system3-nonconformance/web/static/css/issues-archive.css @@ -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; +} diff --git a/system3-nonconformance/web/static/css/issues-dashboard.css b/system3-nonconformance/web/static/css/issues-dashboard.css new file mode 100644 index 0000000..948fbca --- /dev/null +++ b/system3-nonconformance/web/static/css/issues-dashboard.css @@ -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; +} diff --git a/system3-nonconformance/web/static/css/issues-inbox.css b/system3-nonconformance/web/static/css/issues-inbox.css new file mode 100644 index 0000000..9b3075f --- /dev/null +++ b/system3-nonconformance/web/static/css/issues-inbox.css @@ -0,0 +1,6 @@ +/* issues-inbox.css — 수신함 페이지 전용 스타일 */ + +.issue-card.unread { + border-left-color: #3b82f6; + background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%); +} diff --git a/system3-nonconformance/web/static/css/issues-management.css b/system3-nonconformance/web/static/css/issues-management.css new file mode 100644 index 0000000..f3ace95 --- /dev/null +++ b/system3-nonconformance/web/static/css/issues-management.css @@ -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; +} diff --git a/system3-nonconformance/web/static/css/tkqc-common.css b/system3-nonconformance/web/static/css/tkqc-common.css index 63753a3..0cadc22 100644 --- a/system3-nonconformance/web/static/css/tkqc-common.css +++ b/system3-nonconformance/web/static/css/tkqc-common.css @@ -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; + } +} diff --git a/system3-nonconformance/web/static/js/components/mobile-bottom-nav.js b/system3-nonconformance/web/static/js/components/mobile-bottom-nav.js new file mode 100644 index 0000000..ac0d0e5 --- /dev/null +++ b/system3-nonconformance/web/static/js/components/mobile-bottom-nav.js @@ -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 ` + + + ${item.label} + + `; + }).join(''); + + document.body.appendChild(nav); +})(); diff --git a/system3-nonconformance/web/static/js/pages/issue-view.js b/system3-nonconformance/web/static/js/pages/issue-view.js new file mode 100644 index 0000000..d83daff --- /dev/null +++ b/system3-nonconformance/web/static/js/pages/issue-view.js @@ -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 = ` + + 전체 부적합 조회 + `; + descriptionElement.textContent = '모든 사용자가 등록한 부적합 사항을 관리할 수 있습니다'; + } else { + titleElement.innerHTML = ` + + 내 부적합 조회 + `; + descriptionElement.textContent = '내가 등록한 부적합 사항을 확인할 수 있습니다'; + } +} + +// 프로젝트 로드 (API 기반) +async function loadProjects() { + try { + // 모든 프로젝트 로드 (활성/비활성 모두 - 기존 데이터 조회를 위해) + projects = await ProjectsAPI.getAll(false); + const projectFilter = document.getElementById('projectFilter'); + + // 기존 옵션 제거 (전체 프로젝트 옵션 제외) + projectFilter.innerHTML = ''; + + // 모든 프로젝트 추가 + 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 '프로젝트 미지정'; + } + + // 전역 projects 배열에서 찾기 + const project = projects.find(p => p.id == projectId); + if (project) { + return `${project.job_no} / ${project.project_name}`; + } + + return `프로젝트 ID: ${projectId} (정보 없음)`; +} + +// 날짜 범위 설정 및 자동 조회 +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 = ` +
+ +

데이터를 불러오는 중...

+
+ `; + + 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 = ` +
+ +

데이터를 불러오는데 실패했습니다.

+ +
+ `; + } +} + +// 결과 표시 (시간순 나열) +function displayResults() { + const container = document.getElementById('issueResults'); + + // 필터링된 결과 사용 (filterIssues에서 설정됨) + const filteredIssues = window.filteredIssues || issues; + + if (filteredIssues.length === 0) { + const emptyMessage = currentUser.role === 'admin' + ? '조건에 맞는 부적합 사항이 없습니다.' + : '아직 등록한 부적합 사항이 없습니다.
부적합 등록 페이지에서 새로운 부적합을 등록해보세요.'; + + container.innerHTML = ` +
+ +

${emptyMessage}

+ ${currentUser.role !== 'admin' ? ` +
+ + + 부적합 등록하기 + +
+ ` : ''} +
+ `; + 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 = ` +

+ ${config.title} (${issues.length}건) +

+ `; + 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 ` + ${config.text} + `; +} + +// 부적합 사항 카드 생성 함수 (조회용) +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 = ` + +
+
+ ${getWorkflowStatusBadge(issue)} +
+
+ ${projectInfo} +
+
+ + +
+ +
+ ${(() => { + 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 ` +
+ +
+ `; + } + + return photos.map(path => ` + + `).join(''); + })()} +
+ + +
+
+ + ${categoryNames[issue.category] || issue.category} + + ${issue.work_hours ? + ` + ${issue.work_hours}시간 + ` : + '시간 미입력' + } +
+ +

${issue.description}

+ +
+
+ ${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'} + ${dateStr} + ${relativeTime} +
+ + + ${(canEdit || canDelete) ? ` +
+ ${canEdit ? ` + + ` : ''} + ${canDelete ? ` + + ` : ''} +
+ ` : ''} +
+
+
+ `; + + 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 = ` +
+
+

비밀번호 변경

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ `; + + 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 = ` +
+
+

부적합 수정

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ `; + + 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 = ` +
+
+
+ +
+

부적합 삭제

+

+ 이 부적합 사항을 삭제하시겠습니까?
+ 삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다. +

+
+ +
+ + +
+
+ `; + + 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); diff --git a/system3-nonconformance/web/static/js/pages/issues-archive.js b/system3-nonconformance/web/static/js/pages/issues-archive.js new file mode 100644 index 0000000..025bcb0 --- /dev/null +++ b/system3-nonconformance/web/static/js/pages/issues-archive.js @@ -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 ` +
+
+
+
+ ${getStatusText(issue.status)} + ${project ? `${project.project_name}` : ''} + ${completedDate} +
+ +

${issue.description}

+ +
+ ${issue.reporter?.username || '알 수 없음'} + ${issue.category ? `${getCategoryText(issue.category)}` : ''} + ${statusText}: ${completedDate} +
+
+ +
+ +
+
+
+ `; + }).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 = ''; + + 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); diff --git a/system3-nonconformance/web/static/js/pages/issues-dashboard.js b/system3-nonconformance/web/static/js/pages/issues-dashboard.js new file mode 100644 index 0000000..1bd9623 --- /dev/null +++ b/system3-nonconformance/web/static/js/pages/issues-dashboard.js @@ -0,0 +1,1793 @@ +/** + * issues-dashboard.js — 부적합 현황판 페이지 스크립트 + */ + +let currentUser = null; +let allIssues = []; +let projects = []; +let filteredIssues = []; + +// 애니메이션 함수들 +function animateHeaderAppearance() { + const header = document.getElementById('commonHeader'); + if (header) { + header.classList.add('header-fade-in'); + } +} + +function animateContentAppearance() { + const content = document.querySelector('.content-fade-in'); + if (content) { + content.classList.add('visible'); + } +} + +// 페이지 초기화 +async function initializeDashboard() { + try { + // 인증 확인 + currentUser = await window.authManager.checkAuth(); + if (!currentUser) { + showLoginScreen(); + return; + } + + // 페이지 권한 확인 + window.pagePermissionManager.setUser(currentUser); + await window.pagePermissionManager.loadPagePermissions(); + + if (!window.pagePermissionManager.canAccessPage('issues_dashboard')) { + alert('현황판 접근 권한이 없습니다.'); + window.location.href = '/'; + return; + } + + // 공통 헤더 초기화 + if (window.commonHeader) { + await window.commonHeader.init(currentUser, 'issues_dashboard'); + setTimeout(() => animateHeaderAppearance(), 100); + } + + // 데이터 로드 + await Promise.all([ + loadProjects(), + loadInProgressIssues() + ]); + + updateDashboard(); + hideLoadingScreen(); + + } catch (error) { + console.error('대시보드 초기화 실패:', error); + alert('대시보드를 불러오는데 실패했습니다.'); + hideLoadingScreen(); + } +} + +// 로딩 스크린 관리 +function hideLoadingScreen() { + document.getElementById('loadingScreen').style.display = 'none'; +} + +function showLoginScreen() { + document.getElementById('loadingScreen').style.display = 'none'; + document.getElementById('loginScreen').classList.remove('hidden'); +} + +// 데이터 로드 함수들 +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(); + } else { + throw new Error('프로젝트 목록을 불러올 수 없습니다.'); + } + } catch (error) { + console.error('프로젝트 로드 실패:', error); + } +} + +async function loadInProgressIssues() { + try { + const response = await fetch('/api/issues/admin/all', { + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + const allData = await response.json(); + // 진행 중 상태만 필터링 + allIssues = allData.filter(issue => issue.review_status === 'in_progress'); + filteredIssues = [...allIssues]; + } else { + throw new Error('부적합 목록을 불러올 수 없습니다.'); + } + } catch (error) { + console.error('부적합 로드 실패:', error); + } +} + +// 프로젝트 필터 업데이트 +function updateProjectFilter() { + const projectFilter = document.getElementById('projectFilter'); + projectFilter.innerHTML = ''; + + projects.forEach(project => { + const option = document.createElement('option'); + option.value = project.id; + option.textContent = project.project_name; + projectFilter.appendChild(option); + }); +} + +// 대시보드 업데이트 +function updateDashboard() { + updateStatistics(); + updateProjectCards(); +} + +// 통계 업데이트 +function updateStatistics() { + const today = new Date().toDateString(); + + // 오늘 신규 (오늘 수신함에서 진행중으로 넘어온 것들) + const todayIssues = allIssues.filter(issue => + issue.reviewed_at && new Date(issue.reviewed_at).toDateString() === today + ); + + // 완료 대기 (완료 신청이 된 것들) + const pendingCompletionIssues = allIssues.filter(issue => + issue.completion_requested_at && issue.review_status === 'in_progress' + ); + + // 지연 중 (마감일이 지난 것들) + const overdueIssues = allIssues.filter(issue => { + if (!issue.expected_completion_date) return false; + const expectedDate = new Date(issue.expected_completion_date); + const now = new Date(); + return expectedDate < now; // 마감일 지남 + }); + + document.getElementById('totalInProgress').textContent = allIssues.length; + document.getElementById('todayNew').textContent = todayIssues.length; + document.getElementById('pendingCompletion').textContent = pendingCompletionIssues.length; + document.getElementById('overdue').textContent = overdueIssues.length; +} + +// 이슈 카드 업데이트 (관리함 스타일 - 날짜별 그룹화) +function updateProjectCards() { + const container = document.getElementById('projectDashboard'); + const emptyState = document.getElementById('emptyState'); + + if (filteredIssues.length === 0) { + container.innerHTML = ''; + emptyState.classList.remove('hidden'); + return; + } + + emptyState.classList.add('hidden'); + + // 날짜별로 그룹화 (관리함 진입일 기준) + const groupedByDate = {}; + const dateObjects = {}; // 정렬용 Date 객체 저장 + + filteredIssues.forEach(issue => { + // reviewed_at이 있으면 관리함 진입일, 없으면 report_date 사용 + const dateToUse = issue.reviewed_at || issue.report_date; + const dateObj = new Date(dateToUse); + const dateKey = dateObj.toLocaleDateString('ko-KR'); + + if (!groupedByDate[dateKey]) { + groupedByDate[dateKey] = []; + dateObjects[dateKey] = dateObj; + } + groupedByDate[dateKey].push(issue); + }); + + // 날짜별 그룹 생성 + const dateGroups = Object.keys(groupedByDate) + .sort((a, b) => dateObjects[b] - dateObjects[a]) // 최신순 + .map(dateKey => { + const issues = groupedByDate[dateKey]; + const formattedDate = dateObjects[dateKey].toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).replace(/\./g, '. ').trim(); + + return ` +
+
+
+ + ${formattedDate} + (${issues.length}건) + 관리함 진입일 +
+
+
+
+ ${issues.map(issue => createIssueCard(issue)).join('')} +
+
+
+ `; + }).join(''); + + container.innerHTML = dateGroups; +} + +// getIssueTitle, getIssueDetail은 issue-helpers.js에서 제공됨 + +// 이슈 카드 생성 (관리함 진행 중 스타일, 읽기 전용) +function createIssueCard(issue) { + const project = projects.find(p => p.id === issue.project_id); + const projectName = project ? project.project_name : '미지정'; + + // getDepartmentText, getCategoryText는 issue-helpers.js에서 제공됨 + + // 완료 반려 내용 포맷팅 + const formatRejectionContent = (issue) => { + // 1. 새 필드에서 확인 + if (issue.completion_rejection_reason) { + const rejectedAt = issue.completion_rejected_at + ? new Date(issue.completion_rejected_at).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + : ''; + + return rejectedAt + ? `[${rejectedAt}] ${issue.completion_rejection_reason}` + : issue.completion_rejection_reason; + } + + // 2. 기존 데이터에서 패턴 추출 (마이그레이션 전 데이터용) + if (issue.management_comment) { + const rejectionPattern = /\[완료 반려[^\]]*\][^\n]*/g; + const rejections = issue.management_comment.match(rejectionPattern); + return rejections ? rejections.join('\n') : ''; + } + + return ''; + }; + + // solution 파싱 및 카드 형식으로 표시 + const parseSolutionOpinions = (solution, issue) => { + let html = ''; + + // 1. 수신함/관리함 내용 표시 - 항상 표시 + let rawManagementContent = issue.management_comment || issue.final_description || ''; + + // 기존 데이터에서 완료 반려 패턴 제거 (마이그레이션용) + rawManagementContent = rawManagementContent.replace(/\[완료 반려[^\]]*\][^\n]*\n*/g, '').trim(); + + // 기본 텍스트들 필터링 + const defaultTexts = [ + '중복작업 신고용', + '상세 내용 없음', + '자재 누락', + '설계 오류', + '반입 불량', + '검사 누락', + '기타', + '부적합명', + '상세내용', + '상세 내용' + ]; + + const filteredLines = rawManagementContent.split('\n').filter(line => { + const trimmed = line.trim(); + if (!trimmed) return false; + if (defaultTexts.includes(trimmed)) return false; + return true; + }); + + const managementContent = filteredLines.join('\n').trim(); + + const displayContent = managementContent ? managementContent : '확정된 해결 방안 없음'; + const contentStyle = managementContent ? 'text-red-700' : 'text-red-400 italic'; + + html += ` +
+
+ ${displayContent} +
+
+ `; + + // 2. 해결 방안 의견들 표시 + if (!solution || solution.trim() === '') { + return html; + } + + // 구분선으로 의견들을 분리 + const opinions = solution.split(/─{30,}/); + + html += opinions.map((opinion, opinionIndex) => { + const trimmed = opinion.trim(); + if (!trimmed) return ''; + + // [작성자] (날짜시간) 패턴 매칭 + const headerMatch = trimmed.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/); + + if (headerMatch) { + const author = headerMatch[1]; + const datetime = headerMatch[2]; + + // 댓글과 본문 분리 + const lines = trimmed.substring(headerMatch[0].length).trim().split('\n'); + let mainContent = ''; + let comments = []; + let currentCommentIndex = -1; + + for (const line of lines) { + if (line.match(/^\s*└/)) { + const commentMatch = line.match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); + if (commentMatch) { + comments.push({ + author: commentMatch[1], + datetime: commentMatch[2], + content: commentMatch[3], + replies: [] + }); + currentCommentIndex = comments.length - 1; + } + } + else if (line.match(/^\s*↳/)) { + const replyMatch = line.match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); + if (replyMatch && currentCommentIndex >= 0) { + comments[currentCommentIndex].replies.push({ + author: replyMatch[1], + datetime: replyMatch[2], + content: replyMatch[3] + }); + } + } else { + mainContent += (mainContent ? '\n' : '') + line; + } + } + + // 색상 스킴 + const colorSchemes = [ + 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300', + 'bg-gradient-to-r from-blue-50 to-cyan-50 border-blue-300', + 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-300', + 'bg-gradient-to-r from-orange-50 to-yellow-50 border-orange-300', + 'bg-gradient-to-r from-indigo-50 to-violet-50 border-indigo-300' + ]; + const colorScheme = colorSchemes[opinionIndex % colorSchemes.length]; + + const isOwnOpinion = currentUser && (author === currentUser.full_name || author === currentUser.username); + + return ` +
+
+
+
+ ${author.charAt(0)} +
+
+ ${author} +
+ + ${datetime} +
+
+
+
+ + ${isOwnOpinion ? ` + + + ` : ''} +
+
+
${mainContent}
+ + ${comments.length > 0 ? ` +
+ + +
+ ` : ''} +
+ `; + } else { + return ` +
+
${trimmed}
+
+ `; + } + }).join(''); + + return html; + }; + + // 날짜 포맷팅 + const formatKSTDate = (dateStr) => { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); + }; + + // 상태 체크 함수들 + const getIssueStatus = () => { + if (issue.review_status === 'completed') return 'completed'; + if (issue.completion_requested_at) return 'pending_completion'; + + if (issue.expected_completion_date) { + const expectedDate = new Date(issue.expected_completion_date); + const now = new Date(); + const diffDays = (expectedDate - now) / (1000 * 60 * 60 * 24); + + if (diffDays < 0) return 'overdue'; + if (diffDays <= 3) return 'urgent'; + } + + return 'in_progress'; + }; + + const getStatusConfig = (status) => { + const configs = { + 'in_progress': { + text: '진행 중', + bgColor: 'bg-gradient-to-r from-blue-500 to-blue-600', + icon: 'fas fa-cog fa-spin', + dotColor: 'bg-white' + }, + 'urgent': { + text: '긴급', + bgColor: 'bg-gradient-to-r from-orange-500 to-orange-600', + icon: 'fas fa-exclamation-triangle', + dotColor: 'bg-white' + }, + 'overdue': { + text: '지연됨', + bgColor: 'bg-gradient-to-r from-red-500 to-red-600', + icon: 'fas fa-clock', + dotColor: 'bg-white' + }, + 'pending_completion': { + text: '완료 대기', + bgColor: 'bg-gradient-to-r from-purple-500 to-purple-600', + icon: 'fas fa-hourglass-half', + dotColor: 'bg-white' + }, + 'completed': { + text: '완료됨', + bgColor: 'bg-gradient-to-r from-green-500 to-green-600', + icon: 'fas fa-check-circle', + dotColor: 'bg-white' + } + }; + return configs[status] || configs['in_progress']; + }; + + const currentStatus = getIssueStatus(); + const statusConfig = getStatusConfig(currentStatus); + + return ` +
+ +
+
+
+ No.${issue.project_sequence_no || '-'} +
+ ${projectName} +
+
+

${getIssueTitle(issue)}

+
+
+
+
+
+
+ ${statusConfig.text} + +
+
+ 발생: ${formatKSTDate(issue.report_date)} +
+
+
+ + + ${getCategoryText(issue.category || issue.final_category)} + +
+
+
+ + +
+ +
+
+ + 상세 내용 +
+
+
+ ${getIssueDetail(issue)} +
+
+
+
+
+ + 신고자 & 담당 & 마감 +
+
+
+
+ + 신고자 +
+
${issue.reporter?.full_name || issue.reporter?.username || '-'}
+
+
+
+ + 담당자 +
+
${issue.responsible_person || '-'}
+
+
+
+ + 마감 +
+
${formatKSTDate(issue.expected_completion_date)}
+
+
+ ${currentStatus === 'in_progress' || currentStatus === 'urgent' || currentStatus === 'overdue' ? ` +
+ +
+ ` : ''} +
+ + +
+
+
+ + 해결 방안 +
+ +
+
+ ${parseSolutionOpinions(issue.solution, issue)} +
+
+
+
+ + 이미지 +
+
+ ${(() => { + 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 ` +
+ +
+ `; + } + + return photos.map((path, idx) => ` +
+ 부적합 사진 ${idx + 1} + +
+
+ `).join(''); + })()} +
+ + + ${(() => { + const rejection = formatRejectionContent(issue); + if (!rejection) return ''; + + return ` +
+
+
+ +
+ 완료 반려 내역 +
+
${rejection}
+
+ `; + })()} + + ${currentStatus === 'pending_completion' ? ` +
+ +
+ ` : ''} +
+
+
+ `; +} + +// openPhotoModal, closePhotoModal은 photo-modal.js에서 제공됨 + +// ESC 키로 모달 닫기 +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + closePhotoModal(); + closeRejectionModal(); + closeOpinionModal(); + closeCompletionRequestModal(); + closeCommentModal(); + closeEditOpinionModal(); + closeReplyModal(); + closeEditCommentModal(); + closeEditReplyModal(); + } +}); + +// 날짜 그룹 토글 기능 +function toggleDateGroup(dateKey) { + const content = document.getElementById(`content-${dateKey}`); + const chevron = document.getElementById(`chevron-${dateKey}`); + + if (content.style.display === 'none') { + content.style.display = 'block'; + chevron.classList.remove('fa-chevron-right'); + chevron.classList.add('fa-chevron-down'); + } else { + content.style.display = 'none'; + chevron.classList.remove('fa-chevron-down'); + chevron.classList.add('fa-chevron-right'); + } +} + +// 필터 및 정렬 함수들 +function filterByProject() { + const projectId = document.getElementById('projectFilter').value; + + if (projectId) { + filteredIssues = allIssues.filter(issue => issue.project_id == projectId); + } else { + filteredIssues = [...allIssues]; + } + + updateProjectCards(); +} + +// getDepartmentText는 issue-helpers.js에서 제공됨 + +function viewIssueDetail(issueId) { + window.location.href = `/issues-management.html#issue-${issueId}`; +} + +function refreshDashboard() { + // 현재 선택된 프로젝트 기준으로 다시 로드 + const selectedProject = document.getElementById('projectFilter').value; + if (selectedProject) { + filterByProject(); + } else { + initializeDashboard(); + } +} + +// 로그인 처리 +document.getElementById('loginForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + try { + const user = await window.authManager.login(username, password); + if (user) { + document.getElementById('loginScreen').classList.add('hidden'); + await initializeDashboard(); + } + } catch (error) { + alert('로그인에 실패했습니다.'); + } +}); + +// 페이지 로드 시 초기화 +document.addEventListener('DOMContentLoaded', () => { + // AuthManager 로드 대기 + const checkAuthManager = () => { + if (window.authManager) { + initializeDashboard(); + } else { + setTimeout(checkAuthManager, 100); + } + }; + checkAuthManager(); +}); + +// ===== 두 번째 스크립트 블록 (API 로드 및 추가 기능) ===== + +function initializeDashboardApp() { + console.log('API 스크립트 로드 완료 (issues-dashboard.html)'); +} + +// 완료 신청 관련 함수들 +let selectedCompletionIssueId = null; +let completionPhotoBase64 = null; + +function openCompletionRequestModal(issueId) { + selectedCompletionIssueId = issueId; + document.getElementById('completionRequestModal').classList.remove('hidden'); + + // 폼 초기화 + document.getElementById('completionRequestForm').reset(); + document.getElementById('photoPreview').classList.add('hidden'); + document.getElementById('photoUploadArea').classList.remove('hidden'); + completionPhotoBase64 = null; +} + +function closeCompletionRequestModal() { + selectedCompletionIssueId = null; + completionPhotoBase64 = null; + document.getElementById('completionRequestModal').classList.add('hidden'); +} + +// 완료 신청 반려 관련 함수들 +let selectedRejectionIssueId = null; + +function openRejectionModal(issueId) { + selectedRejectionIssueId = issueId; + document.getElementById('rejectionModal').classList.remove('hidden'); + document.getElementById('rejectionReason').value = ''; + document.getElementById('rejectionReason').focus(); +} + +function closeRejectionModal() { + selectedRejectionIssueId = null; + document.getElementById('rejectionModal').classList.add('hidden'); +} + +async function submitRejection(event) { + event.preventDefault(); + + if (!selectedRejectionIssueId) { + alert('이슈 ID가 없습니다.'); + return; + } + + const rejectionReason = document.getElementById('rejectionReason').value.trim(); + if (!rejectionReason) { + alert('반려 사유를 입력해주세요.'); + return; + } + + try { + const response = await fetch(`/api/issues/${selectedRejectionIssueId}/reject-completion`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + rejection_reason: rejectionReason + }) + }); + + if (response.ok) { + alert('완료 신청이 반려되었습니다.'); + closeRejectionModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`반려 처리 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('반려 처리 오류:', error); + alert('반려 처리 중 오류가 발생했습니다: ' + error.message); + } +} + +// 댓글 토글 기능 +function toggleComments(issueId, opinionIndex) { + const commentsDiv = document.getElementById(`comments-${issueId}-${opinionIndex}`); + const chevron = document.getElementById(`comment-chevron-${issueId}-${opinionIndex}`); + + if (commentsDiv.classList.contains('hidden')) { + commentsDiv.classList.remove('hidden'); + chevron.classList.add('fa-rotate-180'); + } else { + commentsDiv.classList.add('hidden'); + chevron.classList.remove('fa-rotate-180'); + } +} + +// 의견 제시 모달 관련 +let selectedOpinionIssueId = null; + +function openOpinionModal(issueId) { + selectedOpinionIssueId = issueId; + document.getElementById('opinionModal').classList.remove('hidden'); + document.getElementById('opinionText').value = ''; + document.getElementById('opinionText').focus(); +} + +function closeOpinionModal() { + selectedOpinionIssueId = null; + document.getElementById('opinionModal').classList.add('hidden'); +} + +// 댓글 추가 모달 관련 +let selectedCommentIssueId = null; +let selectedCommentOpinionIndex = null; + +function openCommentModal(issueId, opinionIndex) { + selectedCommentIssueId = issueId; + selectedCommentOpinionIndex = opinionIndex; + document.getElementById('commentModal').classList.remove('hidden'); + document.getElementById('commentText').value = ''; + document.getElementById('commentText').focus(); +} + +function closeCommentModal() { + selectedCommentIssueId = null; + selectedCommentOpinionIndex = null; + document.getElementById('commentModal').classList.add('hidden'); +} + +// 로그 기록 함수 +async function logModification(issueId, action, details) { + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}` + } + }); + + if (issueResponse.ok) { + const issue = await issueResponse.json(); + const modificationLog = issue.modification_log || []; + + modificationLog.push({ + timestamp: new Date().toISOString(), + user: currentUser.full_name || currentUser.username, + user_id: currentUser.id, + action: action, + details: details + }); + + return modificationLog; + } + } catch (error) { + console.error('로그 기록 오류:', error); + } + return null; +} + +async function submitComment(event) { + event.preventDefault(); + + if (!selectedCommentIssueId || selectedCommentOpinionIndex === null) { + alert('대상 의견이 선택되지 않았습니다.'); + return; + } + + const commentText = document.getElementById('commentText').value.trim(); + if (!commentText) { + alert('댓글을 입력해주세요.'); + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${selectedCommentIssueId}`, { + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}` + } + }); + + if (!issueResponse.ok) { + throw new Error('이슈 정보를 가져올 수 없습니다.'); + } + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (selectedCommentOpinionIndex >= opinions.length) { + throw new Error('잘못된 의견 인덱스입니다.'); + } + + const now = new Date(); + const dateStr = now.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + const newComment = ` └ [${currentUser.full_name || currentUser.username}] (${dateStr}): ${commentText}`; + + opinions[selectedCommentOpinionIndex] = opinions[selectedCommentOpinionIndex].trim() + '\n' + newComment; + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedCommentIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + solution: updatedSolution + }) + }); + + if (response.ok) { + await logModification(selectedCommentIssueId, 'comment_added', { + opinion_index: selectedCommentOpinionIndex, + comment: commentText + }); + alert('댓글이 추가되었습니다.'); + closeCommentModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`댓글 추가 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('댓글 추가 오류:', error); + alert('댓글 추가 중 오류가 발생했습니다: ' + error.message); + } +} + +// 답글(대댓글) 관련 +let selectedReplyIssueId = null; +let selectedReplyOpinionIndex = null; +let selectedReplyCommentIndex = null; + +function openReplyModal(issueId, opinionIndex, commentIndex) { + selectedReplyIssueId = issueId; + selectedReplyOpinionIndex = opinionIndex; + selectedReplyCommentIndex = commentIndex; + document.getElementById('replyModal').classList.remove('hidden'); + document.getElementById('replyText').value = ''; + document.getElementById('replyText').focus(); +} + +function closeReplyModal() { + selectedReplyIssueId = null; + selectedReplyOpinionIndex = null; + selectedReplyCommentIndex = null; + document.getElementById('replyModal').classList.add('hidden'); +} + +async function submitReply(event) { + event.preventDefault(); + + if (!selectedReplyIssueId || selectedReplyOpinionIndex === null || selectedReplyCommentIndex === null) { + alert('대상 댓글이 선택되지 않았습니다.'); + return; + } + + const replyText = document.getElementById('replyText').value.trim(); + if (!replyText) { + alert('답글을 입력해주세요.'); + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${selectedReplyIssueId}`, { + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}` + } + }); + + if (!issueResponse.ok) { + throw new Error('이슈 정보를 가져올 수 없습니다.'); + } + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (selectedReplyOpinionIndex >= opinions.length) { + throw new Error('잘못된 의견 인덱스입니다.'); + } + + const now = new Date(); + const dateStr = now.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + const lines = opinions[selectedReplyOpinionIndex].trim().split('\n'); + const newReply = ` ↳ [${currentUser.full_name || currentUser.username}] (${dateStr}): ${replyText}`; + + let commentCount = -1; + let insertIndex = -1; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { + commentCount++; + if (commentCount === selectedReplyCommentIndex) { + insertIndex = i + 1; + while (insertIndex < lines.length && lines[insertIndex].match(/^\s*↳/)) { + insertIndex++; + } + break; + } + } + } + + if (insertIndex >= 0) { + lines.splice(insertIndex, 0, newReply); + opinions[selectedReplyOpinionIndex] = lines.join('\n'); + } + + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedReplyIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + solution: updatedSolution + }) + }); + + if (response.ok) { + await logModification(selectedReplyIssueId, 'reply_added', { + opinion_index: selectedReplyOpinionIndex, + comment_index: selectedReplyCommentIndex, + reply: replyText + }); + alert('답글이 추가되었습니다.'); + closeReplyModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`답글 추가 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('답글 추가 오류:', error); + alert('답글 추가 중 오류가 발생했습니다: ' + error.message); + } +} + +// 의견 수정 관련 +let selectedEditIssueId = null; +let selectedEditOpinionIndex = null; + +async function editOpinion(issueId, opinionIndex) { + selectedEditIssueId = issueId; + selectedEditOpinionIndex = opinionIndex; + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (opinionIndex >= opinions.length) throw new Error('잘못된 의견 인덱스입니다.'); + + const opinion = opinions[opinionIndex].trim(); + const headerMatch = opinion.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/); + + if (headerMatch) { + const lines = opinion.substring(headerMatch[0].length).trim().split('\n'); + let mainContent = ''; + + for (const line of lines) { + if (!line.match(/^\s*[└├]/)) { + mainContent += (mainContent ? '\n' : '') + line; + } + } + + document.getElementById('editOpinionText').value = mainContent; + document.getElementById('editOpinionModal').classList.remove('hidden'); + document.getElementById('editOpinionText').focus(); + } + } catch (error) { + console.error('의견 수정 준비 오류:', error); + alert('의견을 불러오는 중 오류가 발생했습니다: ' + error.message); + } +} + +function closeEditOpinionModal() { + selectedEditIssueId = null; + selectedEditOpinionIndex = null; + document.getElementById('editOpinionModal').classList.add('hidden'); +} + +async function submitEditOpinion(event) { + event.preventDefault(); + + if (!selectedEditIssueId || selectedEditOpinionIndex === null) { + alert('대상 의견이 선택되지 않았습니다.'); + return; + } + + const newText = document.getElementById('editOpinionText').value.trim(); + if (!newText) { + alert('의견 내용을 입력해주세요.'); + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${selectedEditIssueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (selectedEditOpinionIndex >= opinions.length) throw new Error('잘못된 의견 인덱스입니다.'); + + const opinion = opinions[selectedEditOpinionIndex].trim(); + const headerMatch = opinion.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/); + + if (headerMatch) { + const lines = opinion.substring(headerMatch[0].length).trim().split('\n'); + let comments = []; + + for (const line of lines) { + if (line.match(/^\s*[└├]/)) { + comments.push(line); + } + } + + opinions[selectedEditOpinionIndex] = headerMatch[0] + '\n' + newText + + (comments.length > 0 ? '\n' + comments.join('\n') : ''); + } + + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedEditIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + alert('의견이 수정되었습니다.'); + closeEditOpinionModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`의견 수정 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('의견 수정 오류:', error); + alert('의견 수정 중 오류가 발생했습니다: ' + error.message); + } +} + +// 의견 삭제 +async function deleteOpinion(issueId, opinionIndex) { + if (!confirm('이 의견을 삭제하시겠습니까? (댓글도 함께 삭제됩니다)')) return; + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (opinionIndex >= opinions.length) throw new Error('잘못된 의견 인덱스입니다.'); + + const deletedOpinion = opinions[opinionIndex]; + opinions.splice(opinionIndex, 1); + + const updatedSolution = opinions.length > 0 ? opinions.join('\n' + '─'.repeat(50) + '\n') : ''; + + const response = await fetch(`/api/issues/${issueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ solution: updatedSolution || null }) + }); + + if (response.ok) { + await logModification(issueId, 'opinion_deleted', { opinion_index: opinionIndex, deleted_content: deletedOpinion }); + alert('의견이 삭제되었습니다.'); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`의견 삭제 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('의견 삭제 오류:', error); + alert('의견 삭제 중 오류가 발생했습니다: ' + error.message); + } +} + +// 댓글 수정 +let selectedEditCommentIssueId = null; +let selectedEditCommentOpinionIndex = null; +let selectedEditCommentIndex = null; + +async function editComment(issueId, opinionIndex, commentIndex) { + selectedEditCommentIssueId = issueId; + selectedEditCommentOpinionIndex = opinionIndex; + selectedEditCommentIndex = commentIndex; + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[opinionIndex].trim().split('\n'); + + let commentCount = -1; + for (const line of lines) { + if (line.match(/^\s*└/)) { + commentCount++; + if (commentCount === commentIndex) { + const match = line.match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); + if (match) { + document.getElementById('editCommentText').value = match[3]; + document.getElementById('editCommentModal').classList.remove('hidden'); + document.getElementById('editCommentText').focus(); + } + break; + } + } + } + } catch (error) { + console.error('댓글 수정 준비 오류:', error); + alert('댓글을 불러오는 중 오류가 발생했습니다: ' + error.message); + } +} + +function closeEditCommentModal() { + selectedEditCommentIssueId = null; + selectedEditCommentOpinionIndex = null; + selectedEditCommentIndex = null; + document.getElementById('editCommentModal').classList.add('hidden'); +} + +async function submitEditComment(event) { + event.preventDefault(); + + const newText = document.getElementById('editCommentText').value.trim(); + if (!newText) { alert('댓글 내용을 입력해주세요.'); return; } + + try { + const issueResponse = await fetch(`/api/issues/${selectedEditCommentIssueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[selectedEditCommentOpinionIndex].trim().split('\n'); + + let commentCount = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { + commentCount++; + if (commentCount === selectedEditCommentIndex) { + const match = lines[i].match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):/); + if (match) { lines[i] = ` └ [${match[1]}] (${match[2]}): ${newText}`; } + break; + } + } + } + + opinions[selectedEditCommentOpinionIndex] = lines.join('\n'); + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedEditCommentIssueId}/management`, { + method: 'PUT', + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + await logModification(selectedEditCommentIssueId, 'comment_edited', { opinion_index: selectedEditCommentOpinionIndex, comment_index: selectedEditCommentIndex, new_content: newText }); + alert('댓글이 수정되었습니다.'); + closeEditCommentModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`댓글 수정 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('댓글 수정 오류:', error); + alert('댓글 수정 중 오류가 발생했습니다: ' + error.message); + } +} + +// 댓글 삭제 +async function deleteComment(issueId, opinionIndex, commentIndex) { + if (!confirm('이 댓글을 삭제하시겠습니까? (답글도 함께 삭제됩니다)')) return; + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[opinionIndex].trim().split('\n'); + + let commentCount = -1; + let deleteStart = -1; + let deleteEnd = -1; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { + commentCount++; + if (commentCount === commentIndex) { + deleteStart = i; + deleteEnd = i + 1; + while (deleteEnd < lines.length && lines[deleteEnd].match(/^\s*↳/)) { deleteEnd++; } + break; + } + } + } + + if (deleteStart >= 0) { + lines.splice(deleteStart, deleteEnd - deleteStart); + opinions[opinionIndex] = lines.join('\n'); + } + + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${issueId}/management`, { + method: 'PUT', + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + await logModification(issueId, 'comment_deleted', { opinion_index: opinionIndex, comment_index: commentIndex }); + alert('댓글이 삭제되었습니다.'); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`댓글 삭제 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('댓글 삭제 오류:', error); + alert('댓글 삭제 중 오류가 발생했습니다: ' + error.message); + } +} + +// 대댓글(답글) 수정 +let selectedEditReplyIssueId = null; +let selectedEditReplyOpinionIndex = null; +let selectedEditReplyCommentIndex = null; +let selectedEditReplyIndex = null; + +async function editReply(issueId, opinionIndex, commentIndex, replyIndex) { + selectedEditReplyIssueId = issueId; + selectedEditReplyOpinionIndex = opinionIndex; + selectedEditReplyCommentIndex = commentIndex; + selectedEditReplyIndex = replyIndex; + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[opinionIndex].trim().split('\n'); + + let commentCount = -1; + let replyCount = -1; + + for (const line of lines) { + if (line.match(/^\s*└/)) { commentCount++; replyCount = -1; } + else if (line.match(/^\s*↳/) && commentCount === commentIndex) { + replyCount++; + if (replyCount === replyIndex) { + const match = line.match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); + if (match) { + document.getElementById('editReplyText').value = match[3]; + document.getElementById('editReplyModal').classList.remove('hidden'); + document.getElementById('editReplyText').focus(); + } + break; + } + } + } + } catch (error) { + console.error('답글 수정 준비 오류:', error); + alert('답글을 불러오는 중 오류가 발생했습니다: ' + error.message); + } +} + +function closeEditReplyModal() { + selectedEditReplyIssueId = null; + selectedEditReplyOpinionIndex = null; + selectedEditReplyCommentIndex = null; + selectedEditReplyIndex = null; + document.getElementById('editReplyModal').classList.add('hidden'); +} + +async function submitEditReply(event) { + event.preventDefault(); + + const newText = document.getElementById('editReplyText').value.trim(); + if (!newText) { alert('답글 내용을 입력해주세요.'); return; } + + try { + const issueResponse = await fetch(`/api/issues/${selectedEditReplyIssueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[selectedEditReplyOpinionIndex].trim().split('\n'); + + let commentCount = -1; + let replyCount = -1; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { commentCount++; replyCount = -1; } + else if (lines[i].match(/^\s*↳/) && commentCount === selectedEditReplyCommentIndex) { + replyCount++; + if (replyCount === selectedEditReplyIndex) { + const match = lines[i].match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):/); + if (match) { lines[i] = ` ↳ [${match[1]}] (${match[2]}): ${newText}`; } + break; + } + } + } + + opinions[selectedEditReplyOpinionIndex] = lines.join('\n'); + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedEditReplyIssueId}/management`, { + method: 'PUT', + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + await logModification(selectedEditReplyIssueId, 'reply_edited', { opinion_index: selectedEditReplyOpinionIndex, comment_index: selectedEditReplyCommentIndex, reply_index: selectedEditReplyIndex, new_content: newText }); + alert('답글이 수정되었습니다.'); + closeEditReplyModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`답글 수정 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('답글 수정 오류:', error); + alert('답글 수정 중 오류가 발생했습니다: ' + error.message); + } +} + +// 대댓글(답글) 삭제 +async function deleteReply(issueId, opinionIndex, commentIndex, replyIndex) { + if (!confirm('이 답글을 삭제하시겠습니까?')) return; + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[opinionIndex].trim().split('\n'); + + let commentCount = -1; + let replyCount = -1; + let deleteIndex = -1; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { commentCount++; replyCount = -1; } + else if (lines[i].match(/^\s*↳/) && commentCount === commentIndex) { + replyCount++; + if (replyCount === replyIndex) { deleteIndex = i; break; } + } + } + + if (deleteIndex >= 0) { + lines.splice(deleteIndex, 1); + opinions[opinionIndex] = lines.join('\n'); + } + + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${issueId}/management`, { + method: 'PUT', + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + await logModification(issueId, 'reply_deleted', { opinion_index: opinionIndex, comment_index: commentIndex, reply_index: replyIndex }); + alert('답글이 삭제되었습니다.'); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`답글 삭제 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('답글 삭제 오류:', error); + alert('답글 삭제 중 오류가 발생했습니다: ' + error.message); + } +} + +async function submitOpinion(event) { + event.preventDefault(); + + if (!selectedOpinionIssueId) { + alert('이슈 ID가 없습니다.'); + return; + } + + const opinionText = document.getElementById('opinionText').value.trim(); + if (!opinionText) { + alert('의견을 입력해주세요.'); + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${selectedOpinionIssueId}`, { + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + + const now = new Date(); + const dateStr = now.toLocaleString('ko-KR', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit' + }); + const newOpinion = `[${currentUser.full_name || currentUser.username}] (${dateStr})\n${opinionText}`; + + const updatedSolution = issue.solution + ? `${newOpinion}\n${'─'.repeat(50)}\n${issue.solution}` + : newOpinion; + + const response = await fetch(`/api/issues/${selectedOpinionIssueId}/management`, { + method: 'PUT', + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + alert('의견이 추가되었습니다.'); + closeOpinionModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`의견 추가 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('의견 추가 오류:', error); + alert('의견 추가 중 오류가 발생했습니다: ' + error.message); + } +} + +function handleCompletionPhotoUpload(event) { + const file = event.target.files[0]; + if (!file) return; + + if (file.size > 5 * 1024 * 1024) { alert('파일 크기는 5MB 이하여야 합니다.'); return; } + if (!file.type.startsWith('image/')) { alert('이미지 파일만 업로드 가능합니다.'); return; } + + const reader = new FileReader(); + reader.onload = function(e) { + completionPhotoBase64 = e.target.result; + + document.getElementById('previewImage').src = e.target.result; + document.getElementById('photoUploadArea').classList.add('hidden'); + document.getElementById('photoPreview').classList.remove('hidden'); + }; + reader.readAsDataURL(file); +} + +// 완료 신청 폼 제출 처리 +document.addEventListener('DOMContentLoaded', function() { + const completionForm = document.getElementById('completionRequestForm'); + if (completionForm) { + completionForm.addEventListener('submit', async function(e) { + e.preventDefault(); + + if (!selectedCompletionIssueId) { alert('선택된 이슈가 없습니다.'); return; } + if (!completionPhotoBase64) { alert('완료 사진을 업로드해주세요.'); return; } + + const comment = document.getElementById('completionComment').value.trim(); + + try { + const response = await fetch(`/api/issues/${selectedCompletionIssueId}/completion-request`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${TokenManager.getToken()}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ completion_photo: completionPhotoBase64, completion_comment: comment }) + }); + + if (response.ok) { + alert('완료 신청이 성공적으로 제출되었습니다.'); + closeCompletionRequestModal(); + refreshDashboard(); + } else { + const error = await response.json(); + alert(`완료 신청 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('완료 신청 오류:', error); + alert('완료 신청 중 오류가 발생했습니다.'); + } + }); + } +}); + +// API 스크립트 동적 로드 +const script = document.createElement('script'); +script.src = '/static/js/api.js?v=20260213'; +script.onload = initializeDashboardApp; +document.body.appendChild(script); diff --git a/system3-nonconformance/web/static/js/pages/issues-inbox.js b/system3-nonconformance/web/static/js/pages/issues-inbox.js new file mode 100644 index 0000000..5c0215b --- /dev/null +++ b/system3-nonconformance/web/static/js/pages/issues-inbox.js @@ -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 = ''; + + 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 ` +
+
+
+ +
+
+ 검토 대기 + ${project ? `${project.project_name}` : '프로젝트 미지정'} +
+ ID: ${issue.id} +
+ + +

${issue.final_description || issue.description}

+ + +
+
+ + ${issue.reporter?.username || '알 수 없음'} +
+
+ + ${getCategoryText(issue.category || issue.final_category)} +
+
+ + ${photoInfo} +
+
+ + ${timeAgo} +
+
+ + +
+
+
+ + 업로드: ${createdDate} ${createdTime} +
+ ${issue.work_hours > 0 ? `
+ + 공수: ${issue.work_hours}시간 +
` : ''} +
+ ${issue.detail_notes ? `
+ + "${issue.detail_notes}" +
` : ''} +
+ + + ${photoCount > 0 ? ` + + ` : ''} + + +
+ + + +
+
+ +
+
+ `; + }).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 = ` +
+ 이슈 목록을 불러올 수 없습니다. +
+ `; + } +} + +// 관리함 이슈 목록 표시 +function displayManagementIssues(managementIssues) { + const container = document.getElementById('managementIssuesList'); + + if (managementIssues.length === 0) { + container.innerHTML = ` +
+ 동일 프로젝트의 관리함 이슈가 없습니다. +
+ `; + return; + } + + container.innerHTML = managementIssues.map(issue => ` +
+
+
+
+ ${issue.description || issue.final_description} +
+
+ ${getCategoryText(issue.category || issue.final_category)} + 신고자: ${issue.reporter_name} + ${issue.duplicate_count > 0 ? `중복 ${issue.duplicate_count}건` : ''} +
+
+
+ ID: ${issue.id} +
+
+
+ `).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 = ` +
+
프로젝트: ${project ? project.project_name : '미지정'}
+
카테고리: ${getCategoryText(issue.category || issue.final_category)}
+
설명: ${issue.description || issue.final_description}
+
등록자: ${issue.reporter?.username || '알 수 없음'}
+
등록일: ${new Date(issue.report_date).toLocaleDateString('ko-KR')}
+
+ `; + + // 프로젝트 옵션 업데이트 + const reviewProjectSelect = document.getElementById('reviewProjectId'); + reviewProjectSelect.innerHTML = ''; + 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); diff --git a/system3-nonconformance/web/static/js/pages/issues-management.js b/system3-nonconformance/web/static/js/pages/issues-management.js new file mode 100644 index 0000000..f4180d9 --- /dev/null +++ b/system3-nonconformance/web/static/js/pages/issues-management.js @@ -0,0 +1,2165 @@ +/** + * issues-management.js — 관리함 페이지 스크립트 + */ + +let currentUser = null; +let issues = []; +let projects = []; +let filteredIssues = []; +let currentIssueId = null; +let currentTab = 'in_progress'; // 기본값: 진행 중 + +// 완료 반려 패턴 제거 (해결방안 표시용) +function cleanManagementComment(text) { + if (!text) return ''; + // 기존 데이터에서 완료 반려 패턴 제거 + return text.replace(/\[완료 반려[^\]]*\][^\n]*\n*/g, '').trim(); +} + +// API 로드 후 초기화 함수 +async function initializeManagement() { + 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_management'); + + // 페이지 접근 권한 체크 + setTimeout(() => { + if (!canAccessPage('issues_management')) { + alert('관리함 페이지에 접근할 권한이 없습니다.'); + window.location.href = '/index.html'; + return; + } + }, 500); + + // 데이터 로드 + await loadProjects(); + await loadIssues(); + + } 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 loadIssues() { + try { + let 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(); + // 관리함에서는 진행 중(in_progress)과 완료됨(completed) 상태만 표시 + let filteredIssues = allIssues.filter(issue => + issue.review_status === 'in_progress' || issue.review_status === 'completed' + ); + + // 수신함에서 넘어온 순서대로 No. 재할당 (reviewed_at 기준) + filteredIssues.sort((a, b) => new Date(a.reviewed_at) - new Date(b.reviewed_at)); + + // 프로젝트별로 그룹화하여 No. 재할당 + const projectGroups = {}; + filteredIssues.forEach(issue => { + if (!projectGroups[issue.project_id]) { + projectGroups[issue.project_id] = []; + } + projectGroups[issue.project_id].push(issue); + }); + + // 각 프로젝트별로 순번 재할당 + Object.keys(projectGroups).forEach(projectId => { + projectGroups[projectId].forEach((issue, index) => { + issue.project_sequence_no = index + 1; + }); + }); + + issues = filteredIssues; + filterIssues(); + } else { + throw new Error('부적합 목록을 불러올 수 없습니다.'); + } + } catch (error) { + console.error('부적합 로드 실패:', error); + alert('부적합 목록을 불러오는데 실패했습니다.'); + } +} + +// 탭 전환 함수 +function switchTab(tab) { + currentTab = tab; + + // 탭 버튼 스타일 업데이트 + const inProgressTab = document.getElementById('inProgressTab'); + const completedTab = document.getElementById('completedTab'); + const additionalInfoBtn = document.getElementById('additionalInfoBtn'); + + if (tab === 'in_progress') { + inProgressTab.className = 'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors duration-200 bg-blue-500 text-white'; + completedTab.className = 'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors duration-200 text-gray-600 hover:text-gray-900'; + // 진행 중 탭에서만 추가 정보 버튼 표시 + additionalInfoBtn.style.display = 'block'; + } else { + inProgressTab.className = 'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors duration-200 text-gray-600 hover:text-gray-900'; + completedTab.className = 'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors duration-200 bg-green-500 text-white'; + // 완료됨 탭에서는 추가 정보 버튼 숨김 + additionalInfoBtn.style.display = 'none'; + } + + filterIssues(); // 이미 updateStatistics()가 포함됨 +} + +// 통계 업데이트 함수 +function updateStatistics() { + const projectFilter = document.getElementById('projectFilter').value; + + // 선택된 프로젝트에 따른 이슈 필터링 + const projectIssues = projectFilter + ? issues.filter(issue => issue.project_id == projectFilter) + : issues; + + // 상태별 카운트 + const totalCount = projectIssues.length; + const inProgressCount = projectIssues.filter(issue => + issue.review_status === 'in_progress' && !issue.completion_requested_at + ).length; + const pendingCompletionCount = projectIssues.filter(issue => + issue.review_status === 'in_progress' && issue.completion_requested_at + ).length; + const completedCount = projectIssues.filter(issue => issue.review_status === 'completed').length; + + // 통계 업데이트 + document.getElementById('totalCount').textContent = totalCount; + document.getElementById('inProgressCount').textContent = inProgressCount; + document.getElementById('pendingCompletionCount').textContent = pendingCompletionCount; + document.getElementById('completedCount').textContent = completedCount; +} + +// 필터링 및 표시 함수들 +function filterIssues() { + const projectFilter = document.getElementById('projectFilter').value; + + filteredIssues = issues.filter(issue => { + // 현재 탭에 따른 상태 필터링 + if (issue.review_status !== currentTab) return false; + + // 프로젝트 필터링 + if (projectFilter && issue.project_id != projectFilter) return false; + + return true; + }); + + sortIssues(); + displayIssues(); + updateStatistics(); // 통계 업데이트 추가 +} + +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); + 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'); + + // 날짜별로 그룹화 (상태에 따라 다른 날짜 기준 사용) + const groupedByDate = {}; + filteredIssues.forEach(issue => { + let date; + if (currentTab === 'in_progress') { + // 진행 중: 업로드한 날짜 기준 + date = new Date(issue.report_date).toLocaleDateString('ko-KR'); + } else { + // 완료됨: 완료된 날짜 기준 (없으면 업로드 날짜) + const completionDate = issue.actual_completion_date || issue.report_date; + date = new Date(completionDate).toLocaleDateString('ko-KR'); + } + + if (!groupedByDate[date]) { + groupedByDate[date] = []; + } + groupedByDate[date].push(issue); + }); + + // 날짜별 그룹을 HTML로 생성 + const dateGroups = Object.keys(groupedByDate).map(date => { + const issues = groupedByDate[date]; + const groupId = `group-${date.replace(/\./g, '-')}`; + + return ` +
+
+
+ +

${date}

+ (${issues.length}건) + + ${currentTab === 'in_progress' ? '업로드일' : '완료일'} + +
+
+ +
+
+ ${issues.map(issue => createIssueRow(issue)).join('')} +
+
+
+ `; + }).join(''); + + container.innerHTML = dateGroups; +} + +// 이슈 행 생성 함수 +function createIssueRow(issue) { + const project = projects.find(p => p.id === issue.project_id); + const isInProgress = issue.review_status === 'in_progress'; + const isCompleted = issue.review_status === 'completed'; + + if (isInProgress) { + // 진행 중 - 편집 가능한 형태 + return createInProgressRow(issue, project); + } else { + // 완료됨 - 입력 여부 표시 + 클릭으로 상세보기 + return createCompletedRow(issue, project); + } +} + +// 진행 중 카드 생성 +function createInProgressRow(issue, project) { + // 상태 판별 + const isPendingCompletion = issue.completion_requested_at; + const isOverdue = issue.expected_completion_date && new Date(issue.expected_completion_date) < new Date(); + const isUrgent = issue.expected_completion_date && + (new Date(issue.expected_completion_date) - new Date()) / (1000 * 60 * 60 * 24) <= 3 && + !isOverdue; + + // 상태 설정 + let statusConfig = { + text: '진행 중', + bgColor: 'bg-gradient-to-r from-blue-500 to-blue-600', + icon: 'fas fa-cog fa-spin', + dotColor: 'bg-white' + }; + + if (isPendingCompletion) { + statusConfig = { + text: '완료 대기', + bgColor: 'bg-gradient-to-r from-purple-500 to-purple-600', + icon: 'fas fa-hourglass-half', + dotColor: 'bg-white' + }; + } else if (isOverdue) { + statusConfig = { + text: '지연됨', + bgColor: 'bg-gradient-to-r from-red-500 to-red-600', + icon: 'fas fa-clock', + dotColor: 'bg-white' + }; + } else if (isUrgent) { + statusConfig = { + text: '긴급', + bgColor: 'bg-gradient-to-r from-orange-500 to-orange-600', + icon: 'fas fa-exclamation-triangle', + dotColor: 'bg-white' + }; + } + + return ` +
+ +
+
+
+
+ No.${issue.project_sequence_no || '-'} +
+
+ ${project ? project.project_name : '프로젝트 미지정'} + +
+
+ ${statusConfig.text} + +
+
+
+

${getIssueTitle(issue)}

+
+
+
+ ${isPendingCompletion ? ` + + + + ` : ` + + + + + `} +
+
+ + +
+ +
+
+
+
+ + +
+ ${!isPendingCompletion ? ` + + ` : ` + + 완료 대기 중 + + `} +
+
+
+ ${getIssueDetail(issue)} +
+
+ +
+ +
+ +
+ + ${getCategoryText(issue.category || issue.final_category)} + +
+
+ +
+ + ${(() => { + 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 '
사진 없음
'; + } + + return ` +
+ ${photos.map((path, idx) => ` + 업로드 사진 ${idx + 1} + `).join('')} +
+ `; + })()} +
+
+ + +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + + ${isPendingCompletion ? ` +
+

+ 완료 신청 정보 +

+
+
+ + ${(() => { + const photos = [ + issue.completion_photo_path, + issue.completion_photo_path2, + issue.completion_photo_path3, + issue.completion_photo_path4, + issue.completion_photo_path5 + ].filter(p => p); + + if (photos.length === 0) { + return '

완료 사진 없음

'; + } + + return ` +
+ ${photos.map(path => ` + 완료 사진 + `).join('')} +
+ `; + })()} +
+
+ +

${issue.completion_comment || '코멘트 없음'}

+
+
+ +

${new Date(issue.completion_requested_at).toLocaleString('ko-KR')}

+
+
+
+ ` : ''} + + +
+
+ + ${statusConfig.text} + + + 신고일: ${new Date(issue.report_date).toLocaleDateString('ko-KR')} + +
+
+
+
+
+ `; +} + +// 완료됨 행 생성 (입력 여부 표시) +function createCompletedRow(issue, project) { + // 완료 날짜 포맷팅 + const completedDate = issue.completed_at ? new Date(issue.completed_at).toLocaleDateString('ko-KR') : '미완료'; + + return ` +
+ +
+
+
+
+ No.${issue.project_sequence_no || '-'} +
+
+ ${project ? project.project_name : '프로젝트 미지정'} + +
+
+ 완료됨 + +
+ 완료일: ${completedDate} +
+
+

${getIssueTitle(issue)}

+
+
+
+ +
+
+ + +
+ +
+

+ + 기본 정보 +

+
+
+

${getIssueDetail(issue)}

+
+
원인분류: ${getCategoryText(issue.final_category || issue.category) || '-'}
+
확인자: ${getReporterNames(issue) || '-'}
+
+
+ + +
+

+ + 관리 정보 +

+
+
해결방안 (확정): ${cleanManagementComment(issue.management_comment) || '-'}
+
담당부서: ${getDepartmentText(issue.responsible_department) || '-'}
+
담당자: ${issue.responsible_person || '-'}
+
원인부서: ${getDepartmentText(issue.cause_department) || '-'}
+
관리 코멘트: ${cleanManagementComment(issue.management_comment) || '-'}
+
+
+ + +
+

+ + 완료 정보 +

+
+ +
+ + ${(() => { + const photos = [ + issue.completion_photo_path, + issue.completion_photo_path2, + issue.completion_photo_path3, + issue.completion_photo_path4, + issue.completion_photo_path5 + ].filter(p => p); + + if (photos.length === 0) { + return '

완료 사진 없음

'; + } + + return ` +
+ ${photos.map(path => ` + 완료 사진 + `).join('')} +
+ `; + })()} +
+ +
+ +

${issue.completion_comment || '코멘트 없음'}

+
+ + ${issue.completion_requested_at ? ` +
+ +

${new Date(issue.completion_requested_at).toLocaleString('ko-KR')}

+
+ ` : ''} +
+
+
+ + +
+

+ + 업로드 사진 +

+ ${(() => { + 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 '
사진 없음
'; + } + + return ` +
+ ${photos.map((path, idx) => ` + 업로드 사진 ${idx + 1} + `).join('')} +
+ `; + })()} +
+
+ `; +} + +// 입력 여부 아이콘 생성 +function getStatusIcon(value) { + if (value && value.toString().trim() !== '') { + return ''; + } else { + return ''; + } +} + +// 사진 상태 아이콘 생성 +function getPhotoStatusIcon(photo1, photo2) { + const count = (photo1 ? 1 : 0) + (photo2 ? 1 : 0); + if (count > 0) { + return `${count}장`; + } else { + return ''; + } +} + +// 테이블 헤더 생성 함수 (더 이상 사용하지 않음 - 모든 탭이 카드 형식으로 변경됨) +function createTableHeader() { + // 레거시 함수 - 더 이상 사용되지 않음 + return ''; +} + +// 편집 가능한 필드 생성 함수 +function createEditableField(fieldName, value, type, issueId, editable, options = null) { + if (!editable) { + return value || '-'; + } + + const fieldId = `${fieldName}_${issueId}`; + + switch (type) { + case 'textarea': + return ``; + case 'select': + if (options) { + const optionsHtml = options.map(opt => + `` + ).join(''); + return ``; + } + break; + case 'date': + return ``; + case 'text': + default: + return ``; + } + + return value || '-'; +} + +// 부서 옵션 생성 함수 +function getDepartmentOptions() { + return [ + { value: '', text: '선택하세요' }, + { value: 'production', text: '생산' }, + { value: 'quality', text: '품질' }, + { value: 'purchasing', text: '구매' }, + { value: 'design', text: '설계' }, + { value: 'sales', text: '영업' } + ]; +} + +// 날짜 그룹 토글 함수 +function toggleDateGroup(groupId) { + const content = document.getElementById(groupId); + const icon = document.getElementById(`icon-${groupId}`); + + if (content.classList.contains('collapsed')) { + content.classList.remove('collapsed'); + icon.style.transform = 'rotate(0deg)'; + } else { + content.classList.add('collapsed'); + icon.style.transform = 'rotate(-90deg)'; + } +} + +// 상태 변경 모달 +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 ${TokenManager.getToken()}`, + '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('상태 변경에 실패했습니다.'); + } +} + +// 완료 처리 함수 +async function completeIssue(issueId) { + if (!confirm('이 부적합을 완료 처리하시겠습니까?')) { + return; + } + + try { + const response = await fetch(`/api/inbox/${issueId}/status`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + review_status: 'completed' + }) + }); + + if (response.ok) { + alert('완료 처리되었습니다.'); + await loadIssues(); // 목록 새로고침 + } else { + const error = await response.json(); + throw new Error(error.detail || '완료 처리에 실패했습니다.'); + } + } catch (error) { + console.error('완료 처리 실패:', error); + alert(error.message || '완료 처리 중 오류가 발생했습니다.'); + } +} + +// 이슈 변경사항 저장 함수 +async function saveIssueChanges(issueId) { + try { + // 편집된 필드들의 값 수집 + const updates = {}; + const fields = ['management_comment', 'responsible_department', 'responsible_person', 'expected_completion_date', 'cause_department']; + + fields.forEach(field => { + const element = document.getElementById(`${field}_${issueId}`); + if (element) { + let value = element.value.trim(); + if (value === '' || value === '선택하세요') { + value = null; + } else if (field === 'expected_completion_date' && value) { + // 날짜 필드는 ISO datetime 형식으로 변환 + value = value + 'T00:00:00'; + } + updates[field] = value; + } + }); + + console.log('Sending updates:', updates); + + // API 호출 + const response = await fetch(`/api/issues/${issueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updates) + }); + + console.log('Response status:', response.status); + + if (response.ok) { + alert('변경사항이 저장되었습니다.'); + await loadIssues(); // 목록 새로고침 + } else { + const errorText = await response.text(); + console.error('API Error Response:', errorText); + let errorMessage = '저장에 실패했습니다.'; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.detail || JSON.stringify(errorJson); + } catch (e) { + errorMessage = errorText || '저장에 실패했습니다.'; + } + throw new Error(errorMessage); + } + } catch (error) { + console.error('저장 실패:', error); + console.error('Error details:', error); + alert(error.message || '저장 중 오류가 발생했습니다.'); + } +} + +// 완료된 이슈 상세보기 모달 함수들 +let currentModalIssueId = null; + +async function openIssueDetailModal(issueId) { + currentModalIssueId = issueId; + const issue = issues.find(i => i.id === issueId); + if (!issue) return; + + const project = projects.find(p => p.id === issue.project_id); + + // 모달 제목 설정 + document.getElementById('modalTitle').innerHTML = ` + + 부적합 + No.${issue.project_sequence_no || '-'} +
+ 상세 정보 +
+ `; + + // 모달 내용 생성 + const modalContent = document.getElementById('modalContent'); + modalContent.innerHTML = createModalContent(issue, project); + + // 모달 표시 + document.getElementById('issueDetailModal').classList.remove('hidden'); +} + +function closeIssueDetailModal() { + document.getElementById('issueDetailModal').classList.add('hidden'); + currentModalIssueId = null; +} + +function createModalContent(issue, project) { + return ` +
+ +
+

기본 정보 (수신함 확정)

+ +
+
+ +
${project ? project.project_name : '-'}
+
+ +
+ +
${issue.final_description || issue.description}
+
+ +
+ +
${getCategoryText(issue.final_category || issue.category)}
+
+ +
+ +
${getReporterNames(issue)}
+
+ +
+ + ${(() => { + 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 '
없음
'; + } + + return ` +
+ ${photos.map((path, idx) => ` + 업로드 사진 ${idx + 1} + `).join('')} +
+ `; + })()} +
+
+
+ + +
+

관리 정보 (편집 가능)

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + ${(() => { + const photos = [ + issue.completion_photo_path, + issue.completion_photo_path2, + issue.completion_photo_path3, + issue.completion_photo_path4, + issue.completion_photo_path5 + ].filter(p => p); + + if (photos.length > 0) { + return ` +
+

현재 완료 사진 (${photos.length}장)

+
+ ${photos.map(path => ` + 완료 사진 + `).join('')} +
+
+ `; + } + return ''; + })()} + + +
+ +

※ 최대 5장까지 업로드 가능합니다. 새로운 사진을 업로드하면 기존 사진을 모두 교체합니다.

+
+
+
+
+
+ `; +} + +async function saveModalChanges() { + if (!currentModalIssueId) return; + + try { + // 편집된 필드들의 값 수집 + const updates = {}; + const fields = ['management_comment', 'responsible_department', 'responsible_person', 'expected_completion_date', 'cause_department']; + + fields.forEach(field => { + const element = document.getElementById(`modal_${field}`); + if (element) { + let value = element.value.trim(); + if (value === '' || value === '선택하세요') { + value = null; + } + updates[field] = value; + } + }); + + // 완료 사진 처리 (최대 5장) + const photoInput = document.getElementById('modal_completion_photo'); + const photoFiles = photoInput.files; + + if (photoFiles && photoFiles.length > 0) { + const maxPhotos = Math.min(photoFiles.length, 5); + + for (let i = 0; i < maxPhotos; i++) { + const base64 = await fileToBase64(photoFiles[i]); + const fieldName = i === 0 ? 'completion_photo' : `completion_photo${i + 1}`; + updates[fieldName] = base64; + } + + console.log(`📸 ${maxPhotos}장의 완료 사진 처리 완료`); + } + + console.log('Modal sending updates:', updates); + + // API 호출 + const response = await fetch(`/api/issues/${currentModalIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updates) + }); + + console.log('Modal response status:', response.status); + + if (response.ok) { + alert('변경사항이 저장되었습니다.'); + closeIssueDetailModal(); + await loadIssues(); // 목록 새로고침 + } else { + const errorText = await response.text(); + console.error('Modal API Error Response:', errorText); + let errorMessage = '저장에 실패했습니다.'; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.detail || JSON.stringify(errorJson); + } catch (e) { + errorMessage = errorText || '저장에 실패했습니다.'; + } + throw new Error(errorMessage); + } + } catch (error) { + console.error('모달 저장 실패:', error); + console.error('Error details:', error); + alert(error.message || '저장 중 오류가 발생했습니다.'); + } +} + +// 파일을 Base64로 변환하는 함수 +function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result); + reader.onerror = error => reject(error); + }); +} + +// 기타 함수들 + +function viewIssueDetail(issueId) { + window.location.href = `/issue-view.html#detail-${issueId}`; +} + +// 유틸리티 함수들 +function updateProjectFilter() { + const projectFilter = document.getElementById('projectFilter'); + projectFilter.innerHTML = ''; + + projects.forEach(project => { + const option = document.createElement('option'); + option.value = project.id; + option.textContent = project.project_name; + projectFilter.appendChild(option); + }); +} + +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 `${p.text}`; +} + +// API 스크립트 동적 로딩 +const script = document.createElement('script'); +script.src = '/static/js/api.js?v=20260213'; +script.onload = function() { + console.log('✅ API 스크립트 로드 완료 (issues-management.js)'); + initializeManagement(); +}; +script.onerror = function() { + console.error('❌ API 스크립트 로드 실패'); +}; +document.head.appendChild(script); + +// 추가 정보 모달 관련 함수들 +let selectedIssueId = null; + +function openAdditionalInfoModal() { + // 진행 중 탭에서 선택된 이슈가 있는지 확인 + const inProgressIssues = allIssues.filter(issue => issue.review_status === 'in_progress'); + + if (inProgressIssues.length === 0) { + alert('진행 중인 부적합이 없습니다.'); + return; + } + + // 첫 번째 진행 중 이슈를 기본 선택 (추후 개선 가능) + selectedIssueId = inProgressIssues[0].id; + + // 기존 데이터 로드 + loadAdditionalInfo(selectedIssueId); + + document.getElementById('additionalInfoModal').classList.remove('hidden'); +} + +function closeAdditionalInfoModal() { + document.getElementById('additionalInfoModal').classList.add('hidden'); + selectedIssueId = null; + + // 폼 초기화 + document.getElementById('additionalInfoForm').reset(); +} + +async function loadAdditionalInfo(issueId) { + try { + const response = await fetch(`/api/management/${issueId}/additional-info`, { + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + const data = await response.json(); + + // 폼에 기존 데이터 채우기 + document.getElementById('causeDepartment').value = data.cause_department || ''; + document.getElementById('responsiblePersonDetail').value = data.responsible_person_detail || ''; + document.getElementById('causeDetail').value = data.cause_detail || ''; + } + } catch (error) { + console.error('추가 정보 로드 실패:', error); + } +} + +// 추가 정보 폼 제출 처리 (요소가 존재할 때만) +const additionalInfoForm = document.getElementById('additionalInfoForm'); +if (additionalInfoForm) { + additionalInfoForm.addEventListener('submit', async function(e) { + e.preventDefault(); + + if (!selectedIssueId) { + alert('선택된 부적합이 없습니다.'); + return; + } + + const formData = { + cause_department: document.getElementById('causeDepartment').value || null, + responsible_person_detail: document.getElementById('responsiblePersonDetail').value || null, + cause_detail: document.getElementById('causeDetail').value || null + }; + + try { + const response = await fetch(`/api/management/${selectedIssueId}/additional-info`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }); + + if (response.ok) { + const result = await response.json(); + alert('추가 정보가 성공적으로 저장되었습니다.'); + closeAdditionalInfoModal(); + + // 목록 새로고침 + loadIssues(); + } else { + const error = await response.json(); + alert(`저장 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('추가 정보 저장 실패:', error); + alert('저장 중 오류가 발생했습니다.'); + } + }); +} + +// 상세 내용 편집 관련 함수들 +function toggleDetailEdit(issueId) { + const displayDiv = document.getElementById(`detail-display-${issueId}`); + const editDiv = document.getElementById(`detail-edit-${issueId}`); + + if (displayDiv && editDiv) { + displayDiv.classList.add('hidden'); + editDiv.classList.remove('hidden'); + + // 텍스트 영역에 포커스 + const textarea = document.getElementById(`detail-textarea-${issueId}`); + if (textarea) { + textarea.focus(); + } + } +} + +function cancelDetailEdit(issueId) { + const displayDiv = document.getElementById(`detail-display-${issueId}`); + const editDiv = document.getElementById(`detail-edit-${issueId}`); + + if (displayDiv && editDiv) { + displayDiv.classList.remove('hidden'); + editDiv.classList.add('hidden'); + + // 원래 값으로 복원 + const issue = issues.find(i => i.id === issueId); + if (issue) { + const textarea = document.getElementById(`detail-textarea-${issueId}`); + if (textarea) { + textarea.value = getIssueDetail(issue); + } + } + } +} + +async function saveDetailEdit(issueId) { + const textarea = document.getElementById(`detail-textarea-${issueId}`); + if (!textarea) return; + + const newDetailContent = textarea.value.trim(); + + try { + // 현재 이슈 정보 가져오기 + const issue = issues.find(i => i.id === issueId); + if (!issue) { + alert('이슈 정보를 찾을 수 없습니다.'); + return; + } + + // 부적합명과 새로운 상세 내용을 결합 + const issueTitle = getIssueTitle(issue); + const combinedDescription = issueTitle + (newDetailContent ? '\n' + newDetailContent : ''); + + const response = await fetch(`/api/management/${issueId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + final_description: combinedDescription + }) + }); + + if (response.ok) { + // 성공 시 이슈 데이터 업데이트 + issue.final_description = combinedDescription; + + // 표시 영역 업데이트 + const displayDiv = document.getElementById(`detail-display-${issueId}`); + if (displayDiv) { + const contentDiv = displayDiv.querySelector('div'); + if (contentDiv) { + contentDiv.textContent = newDetailContent || '상세 내용 없음'; + } + } + + // 편집 모드 종료 + cancelDetailEdit(issueId); + + alert('상세 내용이 성공적으로 저장되었습니다.'); + } else { + const error = await response.json(); + alert(`저장 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('상세 내용 저장 실패:', error); + alert('저장 중 오류가 발생했습니다.'); + } +} + +// 완료 확인 모달 열기 (진행 중 -> 완료 처리용) +function openCompletionConfirmModal(issueId) { + openIssueEditModal(issueId, true); // 완료 처리 모드로 열기 +} + +// 이슈 수정 모달 열기 (모든 진행 중 상태에서 사용) +function openIssueEditModal(issueId, isCompletionMode = false) { + const issue = issues.find(i => i.id === issueId); + if (!issue) return; + + const project = projects.find(p => p.id === issue.project_id); + const isPendingCompletion = issue.completion_requested_at; + + // 모달 내용 생성 + const modalContent = ` +
+
+
+ +
+

+ + 이슈 수정 - No.${issue.project_sequence_no || '-'} +

+ +
+ + +
+ +
+
+

기본 정보

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

업로드 사진

+ ${(() => { + 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 '
사진 없음
'; + } + + return ` +
+ ${photos.map((path, idx) => ` + 업로드 사진 ${idx + 1} + `).join('')} +
+ `; + })()} +
+
+ + +
+
+

관리 정보

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

완료 신청 정보

+
+
+ +
+ ${(() => { + const photos = [ + issue.completion_photo_path, + issue.completion_photo_path2, + issue.completion_photo_path3, + issue.completion_photo_path4, + issue.completion_photo_path5 + ].filter(p => p); + + if (photos.length > 0) { + return ` +
+

현재 완료 사진 (${photos.length}장)

+
+ ${photos.map(path => ` + 완료 사진 + `).join('')} +
+
+ `; + } else { + return ` +
+
+ +

사진 없음

+
+
+ `; + } + })()} +
+ + + +
+

※ 최대 5장까지 업로드 가능합니다. 새로운 사진을 업로드하면 기존 사진을 모두 교체합니다.

+
+
+
+ + +
+ ${isPendingCompletion ? ` +
+ +

${new Date(issue.completion_requested_at).toLocaleString('ko-KR')}

+
+ ` : ''} +
+
+
+
+ + +
+ + + + +
+
+
+
+ `; + + // 모달을 body에 추가 + document.body.insertAdjacentHTML('beforeend', modalContent); + + // 파일 선택 이벤트 리스너 추가 + const fileInput = document.getElementById(`edit-completion-photo-${issue.id}`); + const filenameSpan = document.getElementById(`photo-filename-${issue.id}`); + + if (fileInput && filenameSpan) { + fileInput.addEventListener('change', function(e) { + if (e.target.files && e.target.files.length > 0) { + const fileCount = Math.min(e.target.files.length, 5); + filenameSpan.textContent = `${fileCount}개 파일 선택됨`; + filenameSpan.className = 'text-sm text-green-600 font-medium'; + } else { + filenameSpan.textContent = ''; + filenameSpan.className = 'text-sm text-gray-600'; + } + }); + } +} + +// 이슈 수정 모달 닫기 +function closeIssueEditModal() { + const modal = document.getElementById('issueEditModal'); + if (modal) { + modal.remove(); + } +} + +// 모달에서 이슈 저장 +async function saveIssueFromModal(issueId) { + const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim(); + const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim(); + const category = document.getElementById(`edit-category-${issueId}`).value; + const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim(); + const department = document.getElementById(`edit-department-${issueId}`).value; + const person = document.getElementById(`edit-person-${issueId}`).value.trim(); + const date = document.getElementById(`edit-date-${issueId}`).value; + const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value; + + // 완료 신청 정보 (완료 대기 상태일 때만) + const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`); + const completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`); + + let completionComment = null; + const completionPhotos = {}; // 완료 사진들을 저장할 객체 + + if (completionCommentElement) { + completionComment = completionCommentElement.value.trim(); + } + + // 완료 사진 처리 (최대 5장) + if (completionPhotoElement && completionPhotoElement.files.length > 0) { + try { + const files = completionPhotoElement.files; + const maxPhotos = Math.min(files.length, 5); + + console.log(`🔍 총 ${maxPhotos}개의 완료 사진 업로드 시작`); + + for (let i = 0; i < maxPhotos; i++) { + const file = files[i]; + console.log(`🔍 파일 ${i + 1} 정보:`, { + name: file.name, + size: file.size, + type: file.type + }); + + const base64 = await fileToBase64(file); + const base64Data = base64.split(',')[1]; // Base64 데이터만 추출 + + const fieldName = i === 0 ? 'completion_photo' : `completion_photo${i + 1}`; + completionPhotos[fieldName] = base64Data; + + console.log(`✅ 파일 ${i + 1} 변환 완료 (${fieldName})`); + } + } catch (error) { + console.error('파일 변환 오류:', error); + alert('완료 사진 업로드 중 오류가 발생했습니다.'); + return; + } + } + + if (!title) { + alert('부적합명을 입력해주세요.'); + return; + } + + const combinedDescription = title + (detail ? '\n' + detail : ''); + + const requestBody = { + final_description: combinedDescription, + final_category: category, + management_comment: managementComment || null, + responsible_department: department || null, + responsible_person: person || null, + expected_completion_date: date || null, + cause_department: causeDepartment || null + }; + + // 완료 신청 정보가 있으면 추가 + if (completionComment !== null) { + requestBody.completion_comment = completionComment || null; + } + // 완료 사진들 추가 (최대 5장) + for (const [key, value] of Object.entries(completionPhotos)) { + requestBody[key] = value; + } + + try { + const response = await fetch(`/api/management/${issueId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + if (response.ok) { + // 저장 성공 후 데이터 새로고침하고 모달은 유지 + await initializeManagement(); // 페이지 새로고침 + + // 저장된 이슈 정보 다시 로드하여 모달 업데이트 + const updatedIssue = issues.find(i => i.id === issueId); + if (updatedIssue) { + // 완료 사진이 저장되었는지 확인 + if (updatedIssue.completion_photo_path) { + alert('✅ 완료 사진이 성공적으로 저장되었습니다!'); + } else { + alert('⚠️ 저장은 완료되었지만 완료 사진 저장에 실패했습니다. 다시 시도해주세요.'); + } + + // 모달 내용 업데이트 (완료 사진 표시 갱신) + const photoContainer = document.querySelector(`#issueEditModal img[alt*="완료 사진"]`)?.parentElement; + if (photoContainer && updatedIssue.completion_photo_path) { + // HEIC 파일인지 확인 + const isHeic = updatedIssue.completion_photo_path.toLowerCase().endsWith('.heic'); + + if (isHeic) { + // HEIC 파일은 다운로드 링크로 표시 + photoContainer.innerHTML = ` +
+
+ +
+
+

완료 사진 (HEIC)

+ 다운로드하여 확인 +
+
+ `; + } else { + // 일반 이미지는 미리보기 표시 + photoContainer.innerHTML = ` +
+ 현재 완료 사진 +
+

현재 완료 사진

+

클릭하면 크게 볼 수 있습니다

+
+
+ `; + } + } + } else { + alert('이슈가 성공적으로 저장되었습니다.'); + closeIssueEditModal(); + } + } else { + const error = await response.json(); + alert(`저장 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('저장 오류:', error); + alert('저장 중 오류가 발생했습니다.'); + } +} + +// 완료 대기 상태 관련 함수들 +function editIssue(issueId) { + // 수정 모드로 전환 (완료 대기 상태를 해제) + if (confirm('완료 대기 상태를 해제하고 수정 모드로 전환하시겠습니까?')) { + // 완료 신청 정보 초기화 API 호출 + resetCompletionRequest(issueId); + } +} + +function rejectCompletion(issueId) { + const reason = prompt('반려 사유를 입력하세요:'); + if (reason && reason.trim()) { + // 반려 처리 API 호출 + rejectCompletionRequest(issueId, reason.trim()); + } +} + +function confirmCompletion(issueId) { + // 완료 확인 모달 열기 (수정 가능) - 통합 모달 사용 + openIssueEditModal(issueId, true); +} + +// 완료 신청 초기화 (수정 모드로 전환) +async function resetCompletionRequest(issueId) { + try { + const response = await fetch(`/api/issues/${issueId}/reset-completion`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + alert('완료 대기 상태가 해제되었습니다. 수정이 가능합니다.'); + initializeManagement(); // 페이지 새로고침 + } else { + const error = await response.json(); + alert(`상태 변경 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('상태 변경 오류:', error); + alert('상태 변경 중 오류가 발생했습니다.'); + } +} + +// 완료 신청 반려 +async function rejectCompletionRequest(issueId, reason) { + try { + const response = await fetch(`/api/issues/${issueId}/reject-completion`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + rejection_reason: reason + }) + }); + + if (response.ok) { + alert('완료 신청이 반려되었습니다.'); + initializeManagement(); // 페이지 새로고침 + } else { + const error = await response.json(); + alert(`반려 처리 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('반려 처리 오류:', error); + alert('반려 처리 중 오류가 발생했습니다.'); + } +} + +// 완료 확인 모달 열기 +function openCompletionConfirmModal(issueId) { + const issue = issues.find(i => i.id === issueId); + if (!issue) return; + + const project = projects.find(p => p.id === issue.project_id); + + // 모달 내용 생성 + const modalContent = ` +
+
+
+ +
+

+ + 완료 확인 - No.${issue.project_sequence_no || '-'} +

+ +
+ + +
+ +
+
+

기본 정보

+
+
프로젝트: ${project ? project.project_name : '-'}
+
부적합명: ${getIssueTitle(issue)}
+
상세내용: ${getIssueDetail(issue)}
+
원인분류: ${getCategoryText(issue.final_category || issue.category)}
+
+
+ +
+

관리 정보

+
+
해결방안 (확정): ${cleanManagementComment(issue.management_comment) || '-'}
+
담당부서: ${issue.responsible_department || '-'}
+
담당자: ${issue.responsible_person || '-'}
+
조치예상일: ${issue.expected_completion_date ? new Date(issue.expected_completion_date).toLocaleDateString('ko-KR') : '-'}
+
+
+
+ + +
+
+

완료 신청 정보

+
+
+ 완료 사진: + ${issue.completion_photo_path ? ` +
+ 완료 사진 +
+ ` : '

완료 사진 없음

'} +
+
+ 완료 코멘트: +

${issue.completion_comment || '코멘트 없음'}

+
+
+ 신청일시: +

${new Date(issue.completion_requested_at).toLocaleString('ko-KR')}

+
+
+
+ +
+

업로드 사진

+
+ ${issue.photo_path ? `업로드 사진 1` : '
사진 없음
'} + ${issue.photo_path2 ? `업로드 사진 2` : '
사진 없음
'} +
+
+
+
+ + +
+ + +
+
+
+
+ `; + + // 모달을 body에 추가 + document.body.insertAdjacentHTML('beforeend', modalContent); +} + +// 완료 확인 모달 닫기 +function closeCompletionConfirmModal() { + const modal = document.getElementById('completionConfirmModal'); + if (modal) { + modal.remove(); + } +} + +// 저장 후 완료 처리 (최종확인) +async function saveAndCompleteIssue(issueId) { + if (!confirm('수정 내용을 저장하고 이 부적합을 최종 완료 처리하시겠습니까?\n완료 처리 후에는 수정할 수 없습니다.')) { + return; + } + + const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim(); + const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim(); + const category = document.getElementById(`edit-category-${issueId}`).value; + const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim(); + const department = document.getElementById(`edit-department-${issueId}`).value; + const person = document.getElementById(`edit-person-${issueId}`).value.trim(); + const date = document.getElementById(`edit-date-${issueId}`).value; + const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value; + + // 완료 신청 정보 (완료 대기 상태일 때만) + const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`); + const completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`); + + let completionComment = null; + let completionPhoto = null; + + if (completionCommentElement) { + completionComment = completionCommentElement.value.trim(); + } + + if (completionPhotoElement && completionPhotoElement.files[0]) { + try { + const file = completionPhotoElement.files[0]; + console.log('🔍 업로드할 파일 정보:', { + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified + }); + + const base64 = await fileToBase64(file); + console.log('🔍 Base64 변환 완료 - 전체 길이:', base64.length); + console.log('🔍 Base64 헤더:', base64.substring(0, 50)); + + completionPhoto = base64.split(',')[1]; // Base64 데이터만 추출 + console.log('🔍 헤더 제거 후 길이:', completionPhoto.length); + console.log('🔍 전송할 Base64 시작 부분:', completionPhoto.substring(0, 50)); + } catch (error) { + console.error('파일 변환 오류:', error); + alert('완료 사진 업로드 중 오류가 발생했습니다.'); + return; + } + } + + if (!title) { + alert('부적합명을 입력해주세요.'); + return; + } + + const combinedDescription = title + (detail ? '\n' + detail : ''); + + const requestBody = { + final_description: combinedDescription, + final_category: category, + management_comment: managementComment || null, + responsible_department: department || null, + responsible_person: person || null, + expected_completion_date: date || null, + cause_department: causeDepartment || null, + review_status: 'completed' // 완료 상태로 변경 + }; + + // 완료 신청 정보가 있으면 추가 + if (completionComment !== null) { + requestBody.completion_comment = completionComment || null; + } + if (completionPhoto !== null) { + requestBody.completion_photo = completionPhoto; + } + + try { + // 1. 먼저 수정 내용 저장 + const saveResponse = await fetch(`/api/management/${issueId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + if (saveResponse.ok) { + alert('부적합이 수정되고 최종 완료 처리되었습니다.'); + closeIssueEditModal(); + initializeManagement(); // 페이지 새로고침 + } else { + const error = await saveResponse.json(); + alert(`저장 및 완료 처리 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('저장 및 완료 처리 오류:', error); + alert('저장 및 완료 처리 중 오류가 발생했습니다.'); + } +} + +// 최종 완료 확인 (기존 함수 - 필요시 사용) +async function finalConfirmCompletion(issueId) { + if (!confirm('이 부적합을 최종 완료 처리하시겠습니까?\n완료 처리 후에는 수정할 수 없습니다.')) { + return; + } + + try { + const response = await fetch(`/api/issues/${issueId}/final-completion`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + alert('부적합이 최종 완료 처리되었습니다.'); + closeIssueEditModal(); + initializeManagement(); // 페이지 새로고침 + } else { + const error = await response.json(); + alert(`완료 처리 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('완료 처리 오류:', error); + alert('완료 처리 중 오류가 발생했습니다.'); + } +} + +// 삭제 확인 다이얼로그 +function confirmDeleteIssue(issueId) { + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60]'; + modal.onclick = (e) => { + if (e.target === modal) modal.remove(); + }; + + modal.innerHTML = ` +
+
+
+ +
+

부적합 삭제

+

+ 이 부적합 사항을 삭제하시겠습니까?
+ 삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다. +

+
+ +
+ + +
+
+ `; + + document.body.appendChild(modal); +} + +// 삭제 처리 함수 +async function handleDeleteIssueFromManagement(issueId) { + try { + const response = await fetch(`/api/issues/${issueId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${TokenManager.getToken()}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + alert('부적합이 삭제되었습니다.\n삭제 로그가 기록되었습니다.'); + + // 모달들 닫기 + const deleteModal = document.querySelector('.fixed'); + if (deleteModal) deleteModal.remove(); + + closeIssueEditModal(); + + // 페이지 새로고침 + initializeManagement(); + } else { + const error = await response.json(); + alert(`삭제 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('삭제 오류:', error); + alert('삭제 중 오류가 발생했습니다: ' + error.message); + } +} diff --git a/system3-nonconformance/web/static/js/utils/issue-helpers.js b/system3-nonconformance/web/static/js/utils/issue-helpers.js new file mode 100644 index 0000000..5b6883f --- /dev/null +++ b/system3-nonconformance/web/static/js/utils/issue-helpers.js @@ -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(', '); +} diff --git a/system3-nonconformance/web/static/js/utils/photo-modal.js b/system3-nonconformance/web/static/js/utils/photo-modal.js new file mode 100644 index 0000000..bb9ee95 --- /dev/null +++ b/system3-nonconformance/web/static/js/utils/photo-modal.js @@ -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 = ` +
+ 확대된 사진 + +
+ `; + + 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(); +} diff --git a/system3-nonconformance/web/static/js/utils/toast.js b/system3-nonconformance/web/static/js/utils/toast.js new file mode 100644 index 0000000..5d92305 --- /dev/null +++ b/system3-nonconformance/web/static/js/utils/toast.js @@ -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 = ` + + ${message} + `; + + 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); +} diff --git a/user-management/web/Dockerfile b/user-management/web/Dockerfile index 636b17a..908baa1 100644 --- a/user-management/web/Dockerfile +++ b/user-management/web/Dockerfile @@ -2,6 +2,7 @@ FROM nginx:alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY index.html /usr/share/nginx/html/index.html +COPY static/ /usr/share/nginx/html/static/ EXPOSE 80 diff --git a/user-management/web/index.html b/user-management/web/index.html index ddc07b1..98f6ef8 100644 --- a/user-management/web/index.html +++ b/user-management/web/index.html @@ -4,28 +4,10 @@ 통합 관리 - TK Factory Services + - + @@ -1225,2059 +1207,20 @@ - + + + + + + + + + + + + + + + diff --git a/user-management/web/nginx.conf b/user-management/web/nginx.conf index 3da14c9..f5d25f7 100644 --- a/user-management/web/nginx.conf +++ b/user-management/web/nginx.conf @@ -5,6 +5,29 @@ server { root /usr/share/nginx/html; index index.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 캐시 활성화 (버전 쿼리 스트링으로 무효화) + location ~* \.(js|css)$ { + expires 1h; + add_header Cache-Control "public, no-transform"; + } + + # 정적 파일 (이미지 등) + location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ { + expires 1h; + add_header Cache-Control "public, no-transform"; + } + # 정적 파일 location / { try_files $uri $uri/ /index.html; diff --git a/user-management/web/static/css/tkuser.css b/user-management/web/static/css/tkuser.css new file mode 100644 index 0000000..0ded7cf --- /dev/null +++ b/user-management/web/static/css/tkuser.css @@ -0,0 +1,18 @@ +@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); } +.tab-btn { transition: all 0.2s; } +.tab-btn.active { background: #334155; color: white; } +.tab-btn:not(.active) { color: #64748b; } +.tab-btn:not(.active):hover { background: #e2e8f0; } +.system-section { border-left: 4px solid; } +.system-section.system1 { border-color: #3b82f6; } +.system-section.system3 { border-color: #8b5cf6; } +.group-header { cursor: pointer; user-select: none; } +.group-header:hover { background: #f8fafc; } +.perm-item { transition: all 0.15s; } +.perm-item.checked { background: #f0f9ff; border-color: #93c5fd; } +.toast-message { transition: all 0.3s ease; } +.fade-in { opacity: 0; transform: translateY(10px); transition: opacity 0.4s, transform 0.4s; } +.fade-in.visible { opacity: 1; transform: translateY(0); } diff --git a/user-management/web/static/js/tkuser-core.js b/user-management/web/static/js/tkuser-core.js new file mode 100644 index 0000000..226f32c --- /dev/null +++ b/user-management/web/static/js/tkuser-core.js @@ -0,0 +1,72 @@ +/* ===== Config ===== */ +const API_BASE = '/api'; + +/* ===== Token ===== */ +function _cookieGet(n) { const m = document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : null; } +function _cookieRemove(n) { let c = n + '=; path=/; max-age=0'; if (location.hostname.includes('technicalkorea.net')) c += '; domain=.technicalkorea.net'; document.cookie = c; } +function getToken() { return _cookieGet('sso_token') || localStorage.getItem('sso_token') || localStorage.getItem('access_token'); } +function getLoginUrl() { + const h = location.hostname; + if (h.includes('technicalkorea.net')) return location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(location.href); + return location.protocol + '//' + h + ':30000/login?redirect=' + encodeURIComponent(location.href); +} +function decodeToken(t) { try { return JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))); } catch { return null; } } + +/* ===== API ===== */ +async function api(path, opts = {}) { + const token = getToken(); + const res = await fetch(API_BASE + path, { ...opts, headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '', ...(opts.headers||{}) } }); + if (res.status === 401) { location.href = getLoginUrl(); throw new Error('인증 만료'); } + const data = await res.json(); + if (!res.ok) throw new Error(data.error || data.detail || '요청 실패'); + return data; +} + +/* ===== Toast ===== */ +function showToast(msg, type = 'success') { + document.querySelector('.toast-message')?.remove(); + const el = document.createElement('div'); + el.className = `toast-message fixed bottom-4 right-4 px-4 py-3 rounded-lg text-white z-[10000] shadow-lg ${type==='success'?'bg-emerald-500':'bg-red-500'}`; + el.innerHTML = `${msg}`; + document.body.appendChild(el); + setTimeout(() => { el.classList.add('opacity-0'); setTimeout(() => el.remove(), 300); }, 3000); +} + +/* ===== Helpers ===== */ +const DEPT = { production:'생산', quality:'품질', purchasing:'구매', design:'설계', sales:'영업' }; +function deptLabel(d) { return DEPT[d] || d || ''; } +function formatDate(d) { if (!d) return ''; return d.substring(0, 10); } +function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } + +/* ===== Logout ===== */ +function doLogout() { + if (!confirm('로그아웃?')) return; + _cookieRemove('sso_token'); localStorage.removeItem('sso_token'); localStorage.removeItem('access_token'); localStorage.removeItem('currentUser'); + location.href = getLoginUrl(); +} + +/* ===== State ===== */ +let currentUser = null; + +/* ===== Init ===== */ +async function init() { + const token = getToken(); + if (!token) { location.href = getLoginUrl(); return; } + const decoded = decodeToken(token); + if (!decoded) { location.href = getLoginUrl(); return; } + + currentUser = { id: decoded.user_id||decoded.id, username: decoded.username||decoded.sub, name: decoded.name||decoded.full_name, role: decoded.role||decoded.access_level }; + const dn = currentUser.name || currentUser.username; + document.getElementById('headerUserName').textContent = dn; + document.getElementById('headerUserRole').textContent = currentUser.role === 'admin' ? '관리자' : '사용자'; + document.getElementById('headerUserAvatar').textContent = dn.charAt(0).toUpperCase(); + + if (currentUser.role === 'admin') { + document.getElementById('tabNav').classList.remove('hidden'); + document.getElementById('adminSection').classList.remove('hidden'); + await loadUsers(); + } else { + document.getElementById('passwordChangeSection').classList.remove('hidden'); + } + setTimeout(() => document.querySelector('.fade-in').classList.add('visible'), 50); +} diff --git a/user-management/web/static/js/tkuser-departments.js b/user-management/web/static/js/tkuser-departments.js new file mode 100644 index 0000000..1aa90e0 --- /dev/null +++ b/user-management/web/static/js/tkuser-departments.js @@ -0,0 +1,91 @@ +/* ===== Departments CRUD ===== */ +let departments = [], departmentsLoaded = false; + +async function loadDepartments() { + try { + const r = await api('/departments'); departments = r.data || r; + departmentsLoaded = true; + populateParentDeptSelects(); + displayDepartments(); + } catch (err) { + document.getElementById('departmentList').innerHTML = `

${err.message}

`; + } +} + +function populateParentDeptSelects() { + ['newDeptParent','editDeptParent'].forEach(id => { + const sel = document.getElementById(id); if (!sel) return; + const val = sel.value; + sel.innerHTML = ''; + departments.filter(d => d.is_active !== 0 && d.is_active !== false).forEach(d => { + const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); + }); + sel.value = val; + }); +} + +function displayDepartments() { + const c = document.getElementById('departmentList'); + if (!departments.length) { c.innerHTML = '

등록된 부서가 없습니다.

'; return; } + c.innerHTML = departments.map(d => ` +
+
+
${d.department_name}
+
+ ${d.parent_name ? `상위: ${d.parent_name}` : '최상위'} + 순서: ${d.display_order || 0} + ${d.is_active === 0 || d.is_active === false ? '비활성' : '활성'} +
+
+
+ + ${d.is_active !== 0 && d.is_active !== false ? `` : ''} +
+
`).join(''); +} + +document.getElementById('addDepartmentForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api('/departments', { method: 'POST', body: JSON.stringify({ + department_name: document.getElementById('newDeptName').value.trim(), + parent_id: document.getElementById('newDeptParent').value ? parseInt(document.getElementById('newDeptParent').value) : null, + description: document.getElementById('newDeptDescription').value.trim() || null, + display_order: parseInt(document.getElementById('newDeptOrder').value) || 0 + })}); + showToast('부서가 추가되었습니다.'); document.getElementById('addDepartmentForm').reset(); await loadDepartments(); + } catch(e) { showToast(e.message, 'error'); } +}); + +function editDepartment(id) { + const d = departments.find(x => x.department_id === id); if (!d) return; + document.getElementById('editDeptId').value = d.department_id; + document.getElementById('editDeptName').value = d.department_name; + document.getElementById('editDeptDescription').value = d.description || ''; + document.getElementById('editDeptOrder').value = d.display_order || 0; + document.getElementById('editDeptActive').value = (d.is_active === 0 || d.is_active === false) ? '0' : '1'; + populateParentDeptSelects(); + document.getElementById('editDeptParent').value = d.parent_id || ''; + document.getElementById('editDepartmentModal').classList.remove('hidden'); +} +function closeDepartmentModal() { document.getElementById('editDepartmentModal').classList.add('hidden'); } + +document.getElementById('editDepartmentForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api(`/departments/${document.getElementById('editDeptId').value}`, { method: 'PUT', body: JSON.stringify({ + department_name: document.getElementById('editDeptName').value.trim(), + parent_id: document.getElementById('editDeptParent').value ? parseInt(document.getElementById('editDeptParent').value) : null, + description: document.getElementById('editDeptDescription').value.trim() || null, + display_order: parseInt(document.getElementById('editDeptOrder').value) || 0, + is_active: document.getElementById('editDeptActive').value === '1' + })}); + showToast('수정되었습니다.'); closeDepartmentModal(); await loadDepartments(); + await loadDepartmentsForSelect(); + } catch(e) { showToast(e.message, 'error'); } +}); + +async function deactivateDepartment(id, name) { + if (!confirm(`"${name}" 부서를 비활성화?`)) return; + try { await api(`/departments/${id}`, { method: 'DELETE' }); showToast('부서 비활성화 완료'); await loadDepartments(); } catch(e) { showToast(e.message, 'error'); } +} diff --git a/user-management/web/static/js/tkuser-issue-types.js b/user-management/web/static/js/tkuser-issue-types.js new file mode 100644 index 0000000..f592e76 --- /dev/null +++ b/user-management/web/static/js/tkuser-issue-types.js @@ -0,0 +1,5 @@ +/* ===== Issue Types ===== */ +/* Placeholder module for issue type CRUD operations. + This file is reserved for future issue category management functionality. + Currently, issue types are managed through System 3 permissions in tkuser-users.js. +*/ diff --git a/user-management/web/static/js/tkuser-layout-map.js b/user-management/web/static/js/tkuser-layout-map.js new file mode 100644 index 0000000..0a2d9e1 --- /dev/null +++ b/user-management/web/static/js/tkuser-layout-map.js @@ -0,0 +1,315 @@ +/* ===== Layout Map (구역지도) ===== */ +let layoutMapImage = null; +let mapRegions = []; +let mapCanvas = null; +let mapCtx = null; +let isDrawing = false; +let drawStartX = 0; +let drawStartY = 0; +let currentRect = null; +let selectedMapCategoryId = null; + +// 구역지도 프리뷰 캔버스 클릭 -> 해당 영역의 작업장으로 드릴다운 +document.getElementById('previewCanvas')?.addEventListener('click', function(e) { + if (!previewMapRegions.length) return; + const rect = this.getBoundingClientRect(); + const xPct = ((e.clientX - rect.left) / rect.width) * 100; + const yPct = ((e.clientY - rect.top) / rect.height) * 100; + for (const region of previewMapRegions) { + if (xPct >= region.x_start && xPct <= region.x_end && yPct >= region.y_start && yPct <= region.y_end) { + const wp = workplaces.find(w => w.workplace_id === region.workplace_id); + if (wp) { + selectWorkplaceForEquipments(wp.workplace_id, wp.workplace_name); + } + return; + } + } +}); + +async function loadLayoutPreview(categoryId) { + const cat = workplaceCategories.find(c => c.category_id == categoryId); + if (!cat || !cat.layout_image) { + document.getElementById('layoutPreviewArea').classList.remove('hidden'); + document.getElementById('layoutPreviewCanvas').classList.add('hidden'); + document.getElementById('layoutPreviewArea').innerHTML = '

레이아웃 이미지가 없습니다. "지도 설정"에서 업로드하세요.

'; + return; + } + + document.getElementById('layoutPreviewArea').classList.add('hidden'); + document.getElementById('layoutPreviewCanvas').classList.remove('hidden'); + + const pCanvas = document.getElementById('previewCanvas'); + const pCtx = pCanvas.getContext('2d'); + + const imgUrl = cat.layout_image.startsWith('http') ? cat.layout_image : '/uploads/' + cat.layout_image.replace(/^\/uploads\//, ''); + + const img = new Image(); + img.onload = async function() { + const maxW = 800; + const scale = img.width > maxW ? maxW / img.width : 1; + pCanvas.width = img.width * scale; + pCanvas.height = img.height * scale; + pCtx.drawImage(img, 0, 0, pCanvas.width, pCanvas.height); + + try { + const r = await api(`/workplaces/categories/${categoryId}/map-regions`); + const regions = r.data || []; + previewMapRegions = regions; + regions.forEach(region => { + const x1 = (region.x_start / 100) * pCanvas.width; + const y1 = (region.y_start / 100) * pCanvas.height; + const x2 = (region.x_end / 100) * pCanvas.width; + const y2 = (region.y_end / 100) * pCanvas.height; + pCtx.strokeStyle = '#10b981'; + pCtx.lineWidth = 2; + pCtx.strokeRect(x1, y1, x2 - x1, y2 - y1); + pCtx.fillStyle = 'rgba(16, 185, 129, 0.15)'; + pCtx.fillRect(x1, y1, x2 - x1, y2 - y1); + pCtx.fillStyle = '#10b981'; + pCtx.font = '14px sans-serif'; + pCtx.fillText(region.workplace_name || '', x1 + 5, y1 + 20); + }); + } catch(e) { console.warn('영역 로드 실패:', e); } + }; + img.src = imgUrl; +} + +// 구역지도 모달 +function openLayoutMapModal() { + if (!selectedMapCategoryId) { + showToast('공장을 먼저 선택해주세요.', 'error'); + return; + } + const modal = document.getElementById('layoutMapModal'); + mapCanvas = document.getElementById('regionCanvas'); + mapCtx = mapCanvas.getContext('2d'); + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + loadLayoutMapData(); + updateRegionWorkplaceSelect(); +} + +function closeLayoutMapModal() { + const modal = document.getElementById('layoutMapModal'); + modal.style.display = 'none'; + document.body.style.overflow = ''; + if (mapCanvas) { + mapCanvas.removeEventListener('mousedown', onCanvasMouseDown); + mapCanvas.removeEventListener('mousemove', onCanvasMouseMove); + mapCanvas.removeEventListener('mouseup', onCanvasMouseUp); + mapCanvas.removeEventListener('mouseleave', onCanvasMouseUp); + } + currentRect = null; + if (selectedMapCategoryId) loadLayoutPreview(selectedMapCategoryId); +} + +async function loadLayoutMapData() { + try { + const cat = workplaceCategories.find(c => c.category_id == selectedMapCategoryId); + if (!cat) return; + + const imgDiv = document.getElementById('currentLayoutImage'); + if (cat.layout_image) { + const imgUrl = cat.layout_image.startsWith('http') ? cat.layout_image : '/uploads/' + cat.layout_image.replace(/^\/uploads\//, ''); + imgDiv.innerHTML = `레이아웃`; + loadImageToCanvas(imgUrl); + } else { + imgDiv.innerHTML = '업로드된 이미지가 없습니다'; + } + + const r = await api(`/workplaces/categories/${selectedMapCategoryId}/map-regions`); + mapRegions = r.data || []; + renderRegionList(); + } catch(e) { + console.error('레이아웃 데이터 로딩 오류:', e); + } +} + +function loadImageToCanvas(imgUrl) { + const img = new Image(); + img.onload = function() { + const maxW = 800; + const scale = img.width > maxW ? maxW / img.width : 1; + mapCanvas.width = img.width * scale; + mapCanvas.height = img.height * scale; + mapCtx.drawImage(img, 0, 0, mapCanvas.width, mapCanvas.height); + layoutMapImage = img; + drawExistingRegions(); + setupCanvasEvents(); + }; + img.src = imgUrl; +} + +function updateRegionWorkplaceSelect() { + const sel = document.getElementById('regionWorkplaceSelect'); + if (!sel) return; + const catWps = workplaces.filter(w => w.category_id == selectedMapCategoryId); + let html = ''; + catWps.forEach(wp => { + const hasRegion = mapRegions.some(r => r.workplace_id === wp.workplace_id); + html += ``; + }); + sel.innerHTML = html; +} + +function previewLayoutImage(event) { + const file = event.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = function(e) { + document.getElementById('currentLayoutImage').innerHTML = ` + 미리보기 +

미리보기 (저장하려면 "이미지 업로드" 버튼 클릭)

`; + }; + reader.readAsDataURL(file); +} + +async function uploadLayoutImage() { + const file = document.getElementById('layoutImageFile').files[0]; + if (!file) { showToast('이미지를 선택해주세요.', 'error'); return; } + if (!selectedMapCategoryId) { showToast('공장을 먼저 선택해주세요.', 'error'); return; } + + try { + const fd = new FormData(); + fd.append('image', file); + const token = getToken(); + const res = await fetch(`${API_BASE}/workplaces/categories/${selectedMapCategoryId}/layout-image`, { + method: 'POST', + headers: { 'Authorization': token ? `Bearer ${token}` : '' }, + body: fd + }); + const result = await res.json(); + if (!res.ok) throw new Error(result.error || '업로드 실패'); + + showToast('이미지가 업로드되었습니다.'); + const imgUrl = '/uploads/' + result.data.image_path.replace(/^\/uploads\//, ''); + document.getElementById('currentLayoutImage').innerHTML = `레이아웃`; + loadImageToCanvas(imgUrl); + + // 카테고리 데이터 갱신 + await loadWorkplaceCategories(); + } catch(e) { + showToast(e.message || '업로드 실패', 'error'); + } +} + +// 캔버스 드로잉 +function setupCanvasEvents() { + mapCanvas.removeEventListener('mousedown', onCanvasMouseDown); + mapCanvas.removeEventListener('mousemove', onCanvasMouseMove); + mapCanvas.removeEventListener('mouseup', onCanvasMouseUp); + mapCanvas.removeEventListener('mouseleave', onCanvasMouseUp); + mapCanvas.addEventListener('mousedown', onCanvasMouseDown); + mapCanvas.addEventListener('mousemove', onCanvasMouseMove); + mapCanvas.addEventListener('mouseup', onCanvasMouseUp); + mapCanvas.addEventListener('mouseleave', onCanvasMouseUp); +} + +function onCanvasMouseDown(e) { + const r = mapCanvas.getBoundingClientRect(); + const scaleX = mapCanvas.width / r.width; + const scaleY = mapCanvas.height / r.height; + drawStartX = (e.clientX - r.left) * scaleX; + drawStartY = (e.clientY - r.top) * scaleY; + isDrawing = true; +} + +function onCanvasMouseMove(e) { + if (!isDrawing) return; + const r = mapCanvas.getBoundingClientRect(); + const scaleX = mapCanvas.width / r.width; + const scaleY = mapCanvas.height / r.height; + const curX = (e.clientX - r.left) * scaleX; + const curY = (e.clientY - r.top) * scaleY; + + mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height); + if (layoutMapImage) mapCtx.drawImage(layoutMapImage, 0, 0, mapCanvas.width, mapCanvas.height); + drawExistingRegions(); + + const w = curX - drawStartX; + const h = curY - drawStartY; + mapCtx.strokeStyle = '#3b82f6'; + mapCtx.lineWidth = 3; + mapCtx.strokeRect(drawStartX, drawStartY, w, h); + mapCtx.fillStyle = 'rgba(59, 130, 246, 0.2)'; + mapCtx.fillRect(drawStartX, drawStartY, w, h); + + currentRect = { startX: drawStartX, startY: drawStartY, endX: curX, endY: curY }; +} + +function onCanvasMouseUp() { isDrawing = false; } + +function drawExistingRegions() { + mapRegions.forEach(region => { + const x1 = (region.x_start / 100) * mapCanvas.width; + const y1 = (region.y_start / 100) * mapCanvas.height; + const x2 = (region.x_end / 100) * mapCanvas.width; + const y2 = (region.y_end / 100) * mapCanvas.height; + mapCtx.strokeStyle = '#10b981'; + mapCtx.lineWidth = 2; + mapCtx.strokeRect(x1, y1, x2 - x1, y2 - y1); + mapCtx.fillStyle = 'rgba(16, 185, 129, 0.15)'; + mapCtx.fillRect(x1, y1, x2 - x1, y2 - y1); + mapCtx.fillStyle = '#10b981'; + mapCtx.font = '14px sans-serif'; + mapCtx.fillText(region.workplace_name || '', x1 + 5, y1 + 20); + }); +} + +function clearCurrentRegion() { + currentRect = null; + mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height); + if (layoutMapImage) mapCtx.drawImage(layoutMapImage, 0, 0, mapCanvas.width, mapCanvas.height); + drawExistingRegions(); +} + +async function saveRegion() { + const wpId = document.getElementById('regionWorkplaceSelect').value; + if (!wpId) { showToast('작업장을 선택해주세요.', 'error'); return; } + if (!currentRect) { showToast('영역을 그려주세요.', 'error'); return; } + + try { + const xStart = (Math.min(currentRect.startX, currentRect.endX) / mapCanvas.width * 100).toFixed(2); + const yStart = (Math.min(currentRect.startY, currentRect.endY) / mapCanvas.height * 100).toFixed(2); + const xEnd = (Math.max(currentRect.startX, currentRect.endX) / mapCanvas.width * 100).toFixed(2); + const yEnd = (Math.max(currentRect.startY, currentRect.endY) / mapCanvas.height * 100).toFixed(2); + + const existing = mapRegions.find(r => r.workplace_id == wpId); + const body = { workplace_id: parseInt(wpId), category_id: selectedMapCategoryId, x_start: xStart, y_start: yStart, x_end: xEnd, y_end: yEnd, shape: 'rect' }; + + if (existing) { + await api(`/workplaces/map-regions/${existing.region_id}`, { method: 'PUT', body: JSON.stringify(body) }); + } else { + await api('/workplaces/map-regions', { method: 'POST', body: JSON.stringify(body) }); + } + + showToast('영역이 저장되었습니다.'); + await loadLayoutMapData(); + updateRegionWorkplaceSelect(); + clearCurrentRegion(); + document.getElementById('regionWorkplaceSelect').value = ''; + } catch(e) { showToast(e.message || '저장 실패', 'error'); } +} + +function renderRegionList() { + const div = document.getElementById('regionList'); + if (!mapRegions.length) { div.innerHTML = '

정의된 영역이 없습니다

'; return; } + div.innerHTML = '
' + mapRegions.map(r => ` +
+
+ ${r.workplace_name || ''} + (${Number(r.x_start).toFixed(1)}%, ${Number(r.y_start).toFixed(1)}%) ~ (${Number(r.x_end).toFixed(1)}%, ${Number(r.y_end).toFixed(1)}%) +
+ +
`).join('') + '
'; +} + +async function deleteRegion(regionId) { + if (!confirm('이 영역을 삭제하시겠습니까?')) return; + try { + await api(`/workplaces/map-regions/${regionId}`, { method: 'DELETE' }); + showToast('영역이 삭제되었습니다.'); + await loadLayoutMapData(); + updateRegionWorkplaceSelect(); + } catch(e) { showToast(e.message || '삭제 실패', 'error'); } +} diff --git a/user-management/web/static/js/tkuser-projects.js b/user-management/web/static/js/tkuser-projects.js new file mode 100644 index 0000000..8a3cac8 --- /dev/null +++ b/user-management/web/static/js/tkuser-projects.js @@ -0,0 +1,92 @@ +/* ===== Projects CRUD ===== */ +let projects = [], projectsLoaded = false; + +function statusBadge(status, isActive) { + if (!isActive || isActive === 0 || isActive === false) return '비활성'; + if (status === 'completed') return '완료'; + return '진행중'; +} + +async function loadProjects() { + try { + const r = await api('/projects'); projects = r.data || r; + projectsLoaded = true; + displayProjects(); + } catch (err) { + document.getElementById('projectList').innerHTML = `

${err.message}

`; + } +} + +function displayProjects() { + const c = document.getElementById('projectList'); + if (!projects.length) { c.innerHTML = '

등록된 프로젝트가 없습니다.

'; return; } + c.innerHTML = projects.map(p => ` +
+
+
${p.project_name}
+
+ ${p.job_no} + ${p.site?`${p.site}`:''} + ${p.pm?`${p.pm}`:''} + ${statusBadge(p.project_status, p.is_active)} + ${p.due_date?`${formatDate(p.due_date)}`:''} +
+
+
+ + ${p.is_active?``:''} +
+
`).join(''); +} + +document.getElementById('addProjectForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api('/projects', { method: 'POST', body: JSON.stringify({ + job_no: document.getElementById('newJobNo').value.trim(), + project_name: document.getElementById('newProjectName').value.trim(), + contract_date: document.getElementById('newContractDate').value || null, + due_date: document.getElementById('newDueDate').value || null, + site: document.getElementById('newSite').value.trim() || null, + pm: document.getElementById('newPm').value.trim() || null + })}); + showToast('프로젝트가 추가되었습니다.'); document.getElementById('addProjectForm').reset(); await loadProjects(); + } catch(e) { showToast(e.message, 'error'); } +}); + +function editProject(id) { + const p = projects.find(x => x.project_id === id); if (!p) return; + document.getElementById('editProjectId').value = p.project_id; + document.getElementById('editJobNo').value = p.job_no; + document.getElementById('editProjectName').value = p.project_name; + document.getElementById('editContractDate').value = formatDate(p.contract_date); + document.getElementById('editDueDate').value = formatDate(p.due_date); + document.getElementById('editSite').value = p.site || ''; + document.getElementById('editPm').value = p.pm || ''; + document.getElementById('editProjectStatus').value = p.project_status || 'active'; + document.getElementById('editIsActive').value = p.is_active ? '1' : '0'; + document.getElementById('editProjectModal').classList.remove('hidden'); +} +function closeProjectModal() { document.getElementById('editProjectModal').classList.add('hidden'); } + +document.getElementById('editProjectForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api(`/projects/${document.getElementById('editProjectId').value}`, { method: 'PUT', body: JSON.stringify({ + job_no: document.getElementById('editJobNo').value.trim(), + project_name: document.getElementById('editProjectName').value.trim(), + contract_date: document.getElementById('editContractDate').value || null, + due_date: document.getElementById('editDueDate').value || null, + site: document.getElementById('editSite').value.trim() || null, + pm: document.getElementById('editPm').value.trim() || null, + project_status: document.getElementById('editProjectStatus').value, + is_active: document.getElementById('editIsActive').value === '1' + })}); + showToast('수정되었습니다.'); closeProjectModal(); await loadProjects(); + } catch(e) { showToast(e.message, 'error'); } +}); + +async function deactivateProject(id, name) { + if (!confirm(`"${name}" 프로젝트를 비활성화?`)) return; + try { await api(`/projects/${id}`, { method: 'DELETE' }); showToast('프로젝트 비활성화 완료'); await loadProjects(); } catch(e) { showToast(e.message, 'error'); } +} diff --git a/user-management/web/static/js/tkuser-tabs.js b/user-management/web/static/js/tkuser-tabs.js new file mode 100644 index 0000000..bf388d9 --- /dev/null +++ b/user-management/web/static/js/tkuser-tabs.js @@ -0,0 +1,24 @@ +/* ===== Tab ===== */ +function switchTab(name) { + document.querySelectorAll('[id^="tab-"]').forEach(el => el.classList.add('hidden')); + document.getElementById('tab-' + name)?.classList.remove('hidden'); + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + event.currentTarget.classList.add('active'); + // 사이드바 레이아웃 탭에서 main/nav/header 너비 확장 + const mainEl = document.querySelector('main'); + const navInner = document.getElementById('tabNavInner'); + const headerInner = document.getElementById('headerInner'); + const wideClass = 'max-w-[1600px]'; + const defaultClass = 'max-w-7xl'; + if (name === 'workplaces' || name === 'tasks' || name === 'vacations') { + [mainEl, navInner, headerInner].forEach(el => { el.classList.remove(defaultClass); el.classList.add(wideClass); }); + } else { + [mainEl, navInner, headerInner].forEach(el => { el.classList.remove(wideClass); el.classList.add(defaultClass); }); + } + if (name === 'projects' && !projectsLoaded) loadProjects(); + if (name === 'workers' && !workersLoaded) loadWorkers(); + if (name === 'departments' && !departmentsLoaded) loadDepartments(); + if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces(); + if (name === 'tasks' && !tasksLoaded) loadTasksTab(); + if (name === 'vacations' && !vacationsLoaded) loadVacationsTab(); +} diff --git a/user-management/web/static/js/tkuser-tasks.js b/user-management/web/static/js/tkuser-tasks.js new file mode 100644 index 0000000..7cf795f --- /dev/null +++ b/user-management/web/static/js/tkuser-tasks.js @@ -0,0 +1,218 @@ +/* ===== Tasks CRUD ===== */ +let taskWorkTypes = [], allTasks = [], tasksLoaded = false; +let selectedTaskWorkTypeFilter = null; + +async function loadTasksTab() { + await loadWorkTypes(); + await loadTasks(); + tasksLoaded = true; +} + +async function loadWorkTypes() { + try { + const r = await api('/tasks/work-types'); + taskWorkTypes = r.data || []; + renderWorkTypeSidebar(); + populateTaskWorkTypeSelect(); + } catch(e) { console.warn('공정 로드 실패:', e); } +} + +function renderWorkTypeSidebar() { + const c = document.getElementById('workTypeSidebar'); + if (!c) return; + let html = `
+ 전체 + ${allTasks.length} +
`; + // 카테고리별 그룹핑 + const grouped = {}; + taskWorkTypes.forEach(wt => { + const cat = wt.category || '미분류'; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(wt); + }); + Object.keys(grouped).sort().forEach(cat => { + html += `
${cat}
`; + grouped[cat].forEach(wt => { + const count = allTasks.filter(t => t.work_type_id === wt.id).length; + const isActive = selectedTaskWorkTypeFilter === wt.id; + html += `
+ ${wt.name} +
+ ${count} + +
+
`; + }); + }); + // 미지정 작업 수 + const noType = allTasks.filter(t => !t.work_type_id).length; + if (noType > 0) { + html += `
+ 미지정 + ${noType} +
`; + } + c.innerHTML = html; +} + +function populateTaskWorkTypeSelect() { + const sel = document.getElementById('taskWorkType'); + if (!sel) return; + const val = sel.value; + sel.innerHTML = ''; + taskWorkTypes.forEach(wt => { + sel.innerHTML += ``; + }); + sel.value = val; +} + +function filterTasksByWorkType(wtId) { + selectedTaskWorkTypeFilter = wtId; + renderWorkTypeSidebar(); + displayTasks(); +} + +async function loadTasks() { + try { + const r = await api('/tasks'); + allTasks = r.data || []; + renderWorkTypeSidebar(); + displayTasks(); + } catch(e) { + document.getElementById('taskList').innerHTML = `

${e.message}

`; + } +} + +function displayTasks() { + const c = document.getElementById('taskList'); + let filtered = allTasks; + let label = '전체'; + if (selectedTaskWorkTypeFilter === 0) { + filtered = allTasks.filter(t => !t.work_type_id); + label = '미지정'; + } else if (selectedTaskWorkTypeFilter) { + filtered = allTasks.filter(t => t.work_type_id === selectedTaskWorkTypeFilter); + const wt = taskWorkTypes.find(w => w.id === selectedTaskWorkTypeFilter); + label = wt ? wt.name : ''; + } + document.getElementById('taskFilterLabel').textContent = `- ${label}`; + const active = filtered.filter(t => t.is_active).length; + const inactive = filtered.length - active; + document.getElementById('taskStats').textContent = `활성 ${active} / 비활성 ${inactive}`; + + if (!filtered.length) { c.innerHTML = '

등록된 작업이 없습니다.

'; return; } + c.innerHTML = filtered.map(t => ` +
+
+
${escHtml(t.task_name)}
+
+ ${t.work_type_name ? `${escHtml(t.work_type_name)}` : '미지정'} + ${t.description ? `${escHtml(t.description)}` : ''} + ${t.is_active ? '활성' : '비활성'} +
+
+
+ + +
+
`).join(''); +} + +// 공정 모달 +function openWorkTypeModal(editId) { + document.getElementById('wtEditId').value = ''; + document.getElementById('workTypeForm').reset(); + document.getElementById('workTypeModalTitle').textContent = '공정 추가'; + if (editId) { + const wt = taskWorkTypes.find(w => w.id === editId); + if (!wt) return; + document.getElementById('workTypeModalTitle').textContent = '공정 수정'; + document.getElementById('wtEditId').value = wt.id; + document.getElementById('wtName').value = wt.name || ''; + document.getElementById('wtCategory').value = wt.category || ''; + document.getElementById('wtDesc').value = wt.description || ''; + } + document.getElementById('workTypeModal').classList.remove('hidden'); +} +function closeWorkTypeModal() { document.getElementById('workTypeModal').classList.add('hidden'); } +function editWorkType(id) { openWorkTypeModal(id); } + +document.getElementById('workTypeForm').addEventListener('submit', async e => { + e.preventDefault(); + const editId = document.getElementById('wtEditId').value; + const body = { + name: document.getElementById('wtName').value.trim(), + category: document.getElementById('wtCategory').value.trim() || null, + description: document.getElementById('wtDesc').value.trim() || null + }; + try { + if (editId) { + await api(`/tasks/work-types/${editId}`, { method: 'PUT', body: JSON.stringify(body) }); + showToast('공정이 수정되었습니다.'); + } else { + await api('/tasks/work-types', { method: 'POST', body: JSON.stringify(body) }); + showToast('공정이 추가되었습니다.'); + } + closeWorkTypeModal(); + await loadWorkTypes(); + await loadTasks(); + } catch(e) { showToast(e.message, 'error'); } +}); + +// 작업 모달 +function openTaskModal(editId) { + document.getElementById('taskEditId').value = ''; + document.getElementById('taskForm').reset(); + document.getElementById('taskActive').checked = true; + document.getElementById('taskModalTitle').textContent = '작업 추가'; + populateTaskWorkTypeSelect(); + // 사이드바 필터 선택된 공정 자동 선택 + if (!editId && selectedTaskWorkTypeFilter && selectedTaskWorkTypeFilter !== 0) { + document.getElementById('taskWorkType').value = selectedTaskWorkTypeFilter; + } + if (editId) { + const t = allTasks.find(x => x.task_id === editId); + if (!t) return; + document.getElementById('taskModalTitle').textContent = '작업 수정'; + document.getElementById('taskEditId').value = t.task_id; + document.getElementById('taskName').value = t.task_name || ''; + document.getElementById('taskWorkType').value = t.work_type_id || ''; + document.getElementById('taskDesc').value = t.description || ''; + document.getElementById('taskActive').checked = !!t.is_active; + } + document.getElementById('taskModal').classList.remove('hidden'); +} +function closeTaskModal() { document.getElementById('taskModal').classList.add('hidden'); } +function editTask(id) { openTaskModal(id); } + +document.getElementById('taskForm').addEventListener('submit', async e => { + e.preventDefault(); + const editId = document.getElementById('taskEditId').value; + const body = { + task_name: document.getElementById('taskName').value.trim(), + work_type_id: document.getElementById('taskWorkType').value ? parseInt(document.getElementById('taskWorkType').value) : null, + description: document.getElementById('taskDesc').value.trim() || null, + is_active: document.getElementById('taskActive').checked ? 1 : 0 + }; + try { + if (editId) { + await api(`/tasks/${editId}`, { method: 'PUT', body: JSON.stringify(body) }); + showToast('작업이 수정되었습니다.'); + } else { + await api('/tasks', { method: 'POST', body: JSON.stringify(body) }); + showToast('작업이 추가되었습니다.'); + } + closeTaskModal(); + await loadTasks(); + } catch(e) { showToast(e.message, 'error'); } +}); + +async function deleteTask(id, name) { + if (!confirm(`"${name}" 작업을 삭제하시겠습니까?`)) return; + try { + await api(`/tasks/${id}`, { method: 'DELETE' }); + showToast('작업이 삭제되었습니다.'); + await loadTasks(); + } catch(e) { showToast(e.message, 'error'); } +} diff --git a/user-management/web/static/js/tkuser-users.js b/user-management/web/static/js/tkuser-users.js new file mode 100644 index 0000000..718b329 --- /dev/null +++ b/user-management/web/static/js/tkuser-users.js @@ -0,0 +1,385 @@ +/* ===== Permission Page Definitions ===== */ +const SYSTEM1_PAGES = { + '작업 관리': [ + { key: 's1.dashboard', title: '대시보드', icon: 'fa-chart-line', def: true }, + { key: 's1.work.tbm', title: 'TBM 관리', icon: 'fa-hard-hat', def: true }, + { key: 's1.work.report_create', title: '작업보고서 작성', icon: 'fa-file-pen', def: true }, + { key: 's1.work.analysis', title: '작업 분석', icon: 'fa-magnifying-glass-chart', def: false }, + { key: 's1.work.nonconformity', title: '부적합 현황', icon: 'fa-triangle-exclamation', def: true }, + ], + '공장 관리': [ + { key: 's1.factory.repair_management', title: '시설설비 관리', icon: 'fa-wrench', def: false }, + { key: 's1.inspection.daily_patrol', title: '일일순회점검', icon: 'fa-clipboard-check', def: false }, + { key: 's1.inspection.checkin', title: '출근 체크', icon: 'fa-fingerprint', def: true }, + { key: 's1.inspection.work_status', title: '근무 현황', icon: 'fa-user-clock', def: false }, + ], + '안전 관리': [ + { key: 's1.safety.visit_request', title: '출입 신청', icon: 'fa-id-badge', def: true }, + { key: 's1.safety.management', title: '안전 관리', icon: 'fa-fire-extinguisher', def: false }, + { key: 's1.safety.checklist_manage', title: '체크리스트 관리', icon: 'fa-list-check', def: false }, + ], + '근태 관리': [ + { key: 's1.attendance.my_vacation_info', title: '내 연차 정보', icon: 'fa-umbrella-beach', def: true }, + { key: 's1.attendance.monthly', title: '월간 근태', icon: 'fa-calendar-days', def: true }, + { key: 's1.attendance.vacation_request', title: '휴가 신청', icon: 'fa-paper-plane', def: true }, + { key: 's1.attendance.vacation_management', title: '휴가 관리', icon: 'fa-calendar-check', def: false }, + { key: 's1.attendance.vacation_allocation', title: '휴가 발생 입력', icon: 'fa-calendar-plus', def: false }, + { key: 's1.attendance.annual_overview', title: '연간 휴가 현황', icon: 'fa-chart-pie', def: false }, + ], + '시스템 관리': [ + { key: 's1.admin.workers', title: '작업자 관리', icon: 'fa-people-group', def: false }, + { key: 's1.admin.projects', title: '프로젝트 관리', icon: 'fa-folder-open', def: false }, + { key: 's1.admin.tasks', title: '작업 관리', icon: 'fa-list-check', def: false }, + { key: 's1.admin.workplaces', title: '작업장 관리', icon: 'fa-warehouse', def: false }, + { key: 's1.admin.equipments', title: '설비 관리', icon: 'fa-gears', def: false }, + { key: 's1.admin.issue_categories', title: '신고 카테고리', icon: 'fa-tags', def: false }, + { key: 's1.admin.attendance_report', title: '출퇴근-보고서 대조', icon: 'fa-scale-balanced', def: false }, + ] +}; + +const SYSTEM3_PAGES = { + '메인': [ + { key: 'issues_dashboard', title: '현황판', icon: 'fa-chart-line', def: true }, + { key: 'issues_inbox', title: '수신함', icon: 'fa-inbox', def: true }, + { key: 'issues_management', title: '관리함', icon: 'fa-cog', def: false }, + { key: 'issues_archive', title: '폐기함', icon: 'fa-archive', def: false }, + ], + '업무': [ + { key: 'daily_work', title: '일일 공수', icon: 'fa-calendar-check', def: false }, + { key: 'projects_manage', title: '프로젝트 관리', icon: 'fa-folder-open', def: false }, + ], + '보고서': [ + { key: 'reports', title: '보고서', icon: 'fa-chart-bar', def: false }, + { key: 'reports_daily', title: '일일보고서', icon: 'fa-file-excel', def: false }, + { key: 'reports_weekly', title: '주간보고서', icon: 'fa-calendar-week', def: false }, + { key: 'reports_monthly', title: '월간보고서', icon: 'fa-calendar-alt', def: false }, + ] +}; + +/* ===== Users State ===== */ +let users = [], selectedUserId = null, currentPermissions = {}; + +/* ===== Users CRUD ===== */ +async function loadUsers() { + try { + const r = await api('/users'); users = r.data || r; + displayUsers(); updatePermissionUserSelect(); + } catch (err) { + document.getElementById('userList').innerHTML = `

${err.message}

`; + } +} +function displayUsers() { + const c = document.getElementById('userList'); + if (!users.length) { c.innerHTML = '

등록된 사용자가 없습니다.

'; return; } + c.innerHTML = users.map(u => ` +
+
+
${u.name||u.username}
+
+ ${u.username} + ${u.department?`${deptLabel(u.department)}`:''} + ${u.role==='admin'?'관리자':'사용자'} + ${u.is_active===0||u.is_active===false?'비활성':''} +
+
+
+ + + ${u.username!=='hyungi'?``:''} +
+
`).join(''); +} + +document.getElementById('addUserForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api('/users', { method:'POST', body: JSON.stringify({ username: document.getElementById('newUsername').value.trim(), name: document.getElementById('newFullName').value.trim(), password: document.getElementById('newPassword').value, department: document.getElementById('newDepartment').value||null, role: document.getElementById('newRole').value }) }); + showToast('사용자가 추가되었습니다.'); document.getElementById('addUserForm').reset(); await loadUsers(); + } catch(e) { showToast(e.message,'error'); } +}); + +function editUser(id) { + const u = users.find(x=>x.user_id===id); if(!u) return; + document.getElementById('editUserId').value=u.user_id; document.getElementById('editUsername').value=u.username; + document.getElementById('editFullName').value=u.name||''; document.getElementById('editDepartment').value=u.department||''; document.getElementById('editRole').value=u.role; + document.getElementById('editUserModal').classList.remove('hidden'); +} +function closeEditModal() { document.getElementById('editUserModal').classList.add('hidden'); } + +document.getElementById('editUserForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api(`/users/${document.getElementById('editUserId').value}`, { method:'PUT', body: JSON.stringify({ name: document.getElementById('editFullName').value.trim()||null, department: document.getElementById('editDepartment').value||null, role: document.getElementById('editRole').value }) }); + showToast('수정되었습니다.'); closeEditModal(); await loadUsers(); + } catch(e) { showToast(e.message,'error'); } +}); + +async function resetPassword(id, name) { + if (!confirm(`${name}의 비밀번호를 "000000"으로 초기화?`)) return; + try { await api(`/users/${id}/reset-password`,{method:'POST',body:JSON.stringify({new_password:'000000'})}); showToast(`${name} 비밀번호 초기화 완료`); } catch(e) { showToast(e.message,'error'); } +} +async function deleteUser(id, name) { + if (!confirm(`${name}을(를) 비활성화?`)) return; + try { await api(`/users/${id}`,{method:'DELETE'}); showToast('비활성화 완료'); await loadUsers(); } catch(e) { showToast(e.message,'error'); } +} + +document.getElementById('changePasswordForm').addEventListener('submit', async e => { + e.preventDefault(); + const np = document.getElementById('newPasswordChange').value; + if (np !== document.getElementById('confirmPassword').value) { showToast('비밀번호 불일치','error'); return; } + try { + await api('/users/change-password',{method:'POST',body:JSON.stringify({current_password:document.getElementById('currentPassword').value,new_password:np})}); + showToast('비밀번호 변경 완료'); document.getElementById('changePasswordForm').reset(); + } catch(e) { showToast(e.message,'error'); } +}); + +/* ===== Permissions ===== */ +function updatePermissionUserSelect() { + const sel = document.getElementById('permissionUserSelect'); + sel.innerHTML = ''; + users.filter(u=>u.role==='user').forEach(u => { const o=document.createElement('option'); o.value=u.user_id; o.textContent=`${u.name||u.username} (${u.username})`; sel.appendChild(o); }); +} + +document.getElementById('permissionUserSelect').addEventListener('change', async e => { + selectedUserId = e.target.value; + if (selectedUserId) { + await loadUserPermissions(selectedUserId); + renderPermissionGrid(); + document.getElementById('permissionPanel').classList.remove('hidden'); + document.getElementById('permissionEmpty').classList.add('hidden'); + } else { + document.getElementById('permissionPanel').classList.add('hidden'); + document.getElementById('permissionEmpty').classList.remove('hidden'); + } +}); + +async function loadUserPermissions(userId) { + // 기본값 세팅 + currentPermissions = {}; + const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES }; + Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; }); + try { + const perms = await api(`/users/${userId}/page-permissions`); + (Array.isArray(perms)?perms:[]).forEach(p => { currentPermissions[p.page_name] = !!p.can_access; }); + } catch(e) { console.warn('권한 로드 실패:', e); } +} + +function renderPermissionGrid() { + renderSystemPerms('s1-perms', SYSTEM1_PAGES, 'blue'); + renderSystemPerms('s3-perms', SYSTEM3_PAGES, 'purple'); +} + +function renderSystemPerms(containerId, pageDef, color) { + const container = document.getElementById(containerId); + let html = ''; + Object.entries(pageDef).forEach(([groupName, pages]) => { + const groupId = containerId + '-' + groupName.replace(/\s/g,''); + const allChecked = pages.every(p => currentPermissions[p.key]); + html += ` +
+
+
+ + ${groupName} + ${pages.length} +
+ +
+
+ ${pages.map(p => { + const checked = currentPermissions[p.key] || false; + return ` + `; + }).join('')} +
+
`; + }); + container.innerHTML = html; +} + +function onPermChange(cb) { + const item = cb.closest('.perm-item'); + const icon = item.querySelector('i[data-color]'); + const color = icon.dataset.color; + item.classList.toggle('checked', cb.checked); + icon.classList.toggle(`text-${color}-500`, cb.checked); + icon.classList.toggle('text-gray-400', !cb.checked); + // 그룹 전체 체크박스 동기화 + const group = item.dataset.group; + const groupCbs = document.querySelectorAll(`[data-group="${group}"] input[type="checkbox"]`); + const allChecked = [...groupCbs].every(c => c.checked); + const groupHeader = document.getElementById(group)?.previousElementSibling; + if (groupHeader) { const gc = groupHeader.querySelector('input[type="checkbox"]'); if(gc) gc.checked = allChecked; } +} + +function toggleGroup(groupId) { + const el = document.getElementById(groupId); + const arrow = document.getElementById('arrow-' + groupId); + el.classList.toggle('hidden'); + arrow?.classList.toggle('-rotate-90'); +} + +function toggleGroupAll(groupId, checked) { + document.querySelectorAll(`#${groupId} input[type="checkbox"]`).forEach(cb => { + cb.checked = checked; + onPermChange(cb); + }); +} + +function toggleSystemAll(prefix, checked) { + const containerId = prefix === 's1' ? 's1-perms' : 's3-perms'; + document.querySelectorAll(`#${containerId} input[type="checkbox"]`).forEach(cb => { + cb.checked = checked; + onPermChange(cb); + }); + // 그룹 전체 체크박스도 동기화 + document.querySelectorAll(`#${containerId} .group-header input[type="checkbox"]`).forEach(cb => cb.checked = checked); +} + +// 저장 +document.getElementById('savePermissionsBtn').addEventListener('click', async () => { + if (!selectedUserId) return; + const btn = document.getElementById('savePermissionsBtn'); + const st = document.getElementById('permissionSaveStatus'); + btn.disabled = true; btn.innerHTML = '저장 중...'; + + try { + const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat()]; + const permissions = allPages.map(p => { + const cb = document.getElementById('perm_' + p.key); + return { page_name: p.key, can_access: cb ? cb.checked : false }; + }); + await api('/permissions/bulk-grant', { method:'POST', body: JSON.stringify({ user_id: parseInt(selectedUserId), permissions }) }); + st.textContent = '저장 완료'; st.className = 'text-sm text-emerald-600'; + showToast('권한이 저장되었습니다.'); + setTimeout(() => { st.textContent = ''; }, 3000); + } catch(e) { + st.textContent = e.message; st.className = 'text-sm text-red-500'; + showToast('저장 실패: ' + e.message, 'error'); + } finally { btn.disabled = false; btn.innerHTML = '권한 저장'; } +}); + +/* ===== Workers CRUD ===== */ +let workers = [], workersLoaded = false, departmentsForSelect = []; + +const JOB_TYPE = { leader: '반장', worker: '작업자' }; +function jobTypeBadge(t) { + if (t === 'leader') return '반장'; + if (t === 'worker') return '작업자'; + return t ? `${t}` : ''; +} +function workerStatusBadge(s) { + if (s === 'inactive') return '비활성'; + return '재직'; +} + +async function loadDepartmentsForSelect() { + try { + const r = await api('/departments'); departmentsForSelect = (r.data || r).filter(d => d.is_active !== 0 && d.is_active !== false); + populateDeptSelects(); + } catch(e) { console.warn('부서 로드 실패:', e); } +} +function populateDeptSelects() { + ['newWorkerDept','editWorkerDept'].forEach(id => { + const sel = document.getElementById(id); if (!sel) return; + const val = sel.value; + sel.innerHTML = ''; + departmentsForSelect.forEach(d => { const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); }); + sel.value = val; + }); +} + +async function loadWorkers() { + await loadDepartmentsForSelect(); + try { + const r = await api('/workers'); workers = r.data || r; + workersLoaded = true; + displayWorkers(); + } catch (err) { + document.getElementById('workerList').innerHTML = `

${err.message}

`; + } +} + +function displayWorkers() { + const c = document.getElementById('workerList'); + if (!workers.length) { c.innerHTML = '

등록된 작업자가 없습니다.

'; return; } + c.innerHTML = workers.map(w => ` +
+
+
${w.worker_name}
+
+ ${jobTypeBadge(w.job_type)} + ${w.department_name ? `${w.department_name}` : ''} + ${workerStatusBadge(w.status)} + ${w.phone_number ? `${w.phone_number}` : ''} +
+
+
+ + ${w.status !== 'inactive' ? `` : ''} +
+
`).join(''); +} + +document.getElementById('addWorkerForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api('/workers', { method: 'POST', body: JSON.stringify({ + worker_name: document.getElementById('newWorkerName').value.trim(), + job_type: document.getElementById('newJobType').value || null, + department_id: document.getElementById('newWorkerDept').value ? parseInt(document.getElementById('newWorkerDept').value) : null, + phone_number: document.getElementById('newWorkerPhone').value.trim() || null, + hire_date: document.getElementById('newWorkerHireDate').value || null, + notes: document.getElementById('newWorkerNotes').value.trim() || null + })}); + showToast('작업자가 추가되었습니다.'); document.getElementById('addWorkerForm').reset(); await loadWorkers(); + } catch(e) { showToast(e.message, 'error'); } +}); + +function editWorker(id) { + const w = workers.find(x => x.worker_id === id); if (!w) return; + document.getElementById('editWorkerId').value = w.worker_id; + document.getElementById('editWorkerName').value = w.worker_name; + document.getElementById('editJobType').value = w.job_type || ''; + document.getElementById('editWorkerDept').value = w.department_id || ''; + document.getElementById('editWorkerPhone').value = w.phone_number || ''; + document.getElementById('editWorkerHireDate').value = formatDate(w.hire_date); + document.getElementById('editWorkerNotes').value = w.notes || ''; + document.getElementById('editWorkerStatus').value = w.status || 'active'; + document.getElementById('editEmploymentStatus').value = w.employment_status || 'employed'; + populateDeptSelects(); + document.getElementById('editWorkerDept').value = w.department_id || ''; + document.getElementById('editWorkerModal').classList.remove('hidden'); +} +function closeWorkerModal() { document.getElementById('editWorkerModal').classList.add('hidden'); } + +document.getElementById('editWorkerForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api(`/workers/${document.getElementById('editWorkerId').value}`, { method: 'PUT', body: JSON.stringify({ + worker_name: document.getElementById('editWorkerName').value.trim(), + job_type: document.getElementById('editJobType').value || null, + department_id: document.getElementById('editWorkerDept').value ? parseInt(document.getElementById('editWorkerDept').value) : null, + phone_number: document.getElementById('editWorkerPhone').value.trim() || null, + hire_date: document.getElementById('editWorkerHireDate').value || null, + notes: document.getElementById('editWorkerNotes').value.trim() || null, + status: document.getElementById('editWorkerStatus').value, + employment_status: document.getElementById('editEmploymentStatus').value + })}); + showToast('수정되었습니다.'); closeWorkerModal(); await loadWorkers(); + } catch(e) { showToast(e.message, 'error'); } +}); + +async function deactivateWorker(id, name) { + if (!confirm(`"${name}" 작업자를 비활성화?`)) return; + try { await api(`/workers/${id}`, { method: 'DELETE' }); showToast('작업자 비활성화 완료'); await loadWorkers(); } catch(e) { showToast(e.message, 'error'); } +} diff --git a/user-management/web/static/js/tkuser-vacations.js b/user-management/web/static/js/tkuser-vacations.js new file mode 100644 index 0000000..ea4f9d6 --- /dev/null +++ b/user-management/web/static/js/tkuser-vacations.js @@ -0,0 +1,310 @@ +/* ===== Vacation CRUD ===== */ +let vacTypes = [], vacBalances = [], vacationsLoaded = false, vacWorkers = []; + +async function loadVacationsTab() { + // 연도 셀렉트 초기화 + const sel = document.getElementById('vacYear'); + if (sel && !sel.children.length) { + const curYear = new Date().getFullYear(); + for (let y = curYear + 1; y >= curYear - 2; y--) { + const o = document.createElement('option'); + o.value = y; o.textContent = y + '년'; + if (y === curYear) o.selected = true; + sel.appendChild(o); + } + } + await loadVacTypes(); + await loadVacWorkers(); + await loadVacBalances(); + vacationsLoaded = true; +} + +async function loadVacTypes() { + try { + const r = await api('/vacations/types?all=true'); + vacTypes = r.data || []; + renderVacTypeSidebar(); + } catch(e) { console.warn('휴가 유형 로드 실패:', e); } +} + +async function loadVacWorkers() { + try { + const r = await api('/workers'); + vacWorkers = (r.data || []).filter(w => w.status !== 'inactive'); + } catch(e) { console.warn('작업자 로드 실패:', e); } +} + +function renderVacTypeSidebar() { + const c = document.getElementById('vacTypeSidebar'); + if (!c) return; + if (!vacTypes.length) { c.innerHTML = '

등록된 유형이 없습니다.

'; return; } + c.innerHTML = vacTypes.map(vt => ` +
+
+
+ ${vt.type_name} + ${vt.is_system ? '시스템' : ''} + ${vt.is_special ? '특별' : ''} + ${!vt.is_active ? '비활성' : ''} +
+
+ ${vt.type_code} | 차감 ${vt.deduct_days}일 | 우선순위 ${vt.priority} +
+
+
+ + ${!vt.is_system ? `` : ''} +
+
`).join(''); +} + +// 유형 모달 +function openVacTypeModal(editId) { + document.getElementById('vtEditId').value = ''; + document.getElementById('vacTypeForm').reset(); + document.getElementById('vtDeductDays').value = '1.0'; + document.getElementById('vtPriority').value = '99'; + document.getElementById('vacTypeModalTitle').textContent = '휴가 유형 추가'; + document.getElementById('vtCode').readOnly = false; + if (editId) { + const vt = vacTypes.find(v => v.id === editId); + if (!vt) return; + document.getElementById('vacTypeModalTitle').textContent = '휴가 유형 수정'; + document.getElementById('vtEditId').value = vt.id; + document.getElementById('vtCode').value = vt.type_code; + document.getElementById('vtCode').readOnly = !!vt.is_system; + document.getElementById('vtName').value = vt.type_name; + document.getElementById('vtDeductDays').value = vt.deduct_days; + document.getElementById('vtPriority').value = vt.priority; + document.getElementById('vtDescription').value = vt.description || ''; + document.getElementById('vtSpecial').checked = !!vt.is_special; + } + document.getElementById('vacTypeModal').classList.remove('hidden'); +} +function closeVacTypeModal() { document.getElementById('vacTypeModal').classList.add('hidden'); } +function editVacType(id) { openVacTypeModal(id); } + +document.getElementById('vacTypeForm').addEventListener('submit', async e => { + e.preventDefault(); + const editId = document.getElementById('vtEditId').value; + const body = { + type_code: document.getElementById('vtCode').value.trim().toUpperCase(), + type_name: document.getElementById('vtName').value.trim(), + deduct_days: parseFloat(document.getElementById('vtDeductDays').value) || 1.0, + priority: parseInt(document.getElementById('vtPriority').value) || 99, + description: document.getElementById('vtDescription').value.trim() || null, + is_special: document.getElementById('vtSpecial').checked + }; + try { + if (editId) { + await api(`/vacations/types/${editId}`, { method: 'PUT', body: JSON.stringify(body) }); + showToast('휴가 유형이 수정되었습니다.'); + } else { + await api('/vacations/types', { method: 'POST', body: JSON.stringify(body) }); + showToast('휴가 유형이 추가되었습니다.'); + } + closeVacTypeModal(); await loadVacTypes(); + } catch(e) { showToast(e.message, 'error'); } +}); + +async function deleteVacType(id, name) { + if (!confirm(`"${name}" 유형을 비활성화하시겠습니까?`)) return; + try { await api(`/vacations/types/${id}`, { method: 'DELETE' }); showToast('비활성화되었습니다.'); await loadVacTypes(); } + catch(e) { showToast(e.message, 'error'); } +} + +// 연차 배정 테이블 +async function loadVacBalances() { + const year = document.getElementById('vacYear')?.value || new Date().getFullYear(); + try { + const r = await api(`/vacations/balances/year/${year}`); + vacBalances = r.data || []; + renderVacBalanceTable(); + } catch(e) { + document.getElementById('vacBalanceTable').innerHTML = `

${e.message}

`; + } +} + +let vacDeptCollapsed = {}; + +function toggleVacDept(deptName) { + vacDeptCollapsed[deptName] = !vacDeptCollapsed[deptName]; + const body = document.getElementById('vacDept_' + CSS.escape(deptName)); + const icon = document.getElementById('vacDeptIcon_' + CSS.escape(deptName)); + if (body) body.classList.toggle('hidden'); + if (icon) icon.style.transform = vacDeptCollapsed[deptName] ? 'rotate(-90deg)' : ''; +} + +function renderVacBalanceTable() { + const c = document.getElementById('vacBalanceTable'); + if (!vacBalances.length) { + c.innerHTML = '

배정된 연차가 없습니다. "자동 계산" 또는 "개별 배정"을 이용하세요.

'; + return; + } + // 부서 -> 작업자 -> 휴가유형 그룹핑 + const deptMap = {}; + vacBalances.forEach(b => { + const deptName = b.department_name || '미배정'; + if (!deptMap[deptName]) deptMap[deptName] = {}; + if (!deptMap[deptName][b.worker_id]) deptMap[deptName][b.worker_id] = { name: b.worker_name, hire_date: b.hire_date, items: [] }; + deptMap[deptName][b.worker_id].items.push(b); + }); + + const deptNames = Object.keys(deptMap).sort((a, b) => a === '미배정' ? 1 : b === '미배정' ? -1 : a.localeCompare(b)); + let html = '
'; + + deptNames.forEach(deptName => { + const workers = deptMap[deptName]; + const workerCount = Object.keys(workers).length; + // 부서 합계 + let dTotal = 0, dUsed = 0; + Object.values(workers).forEach(g => g.items.forEach(b => { dTotal += parseFloat(b.total_days) || 0; dUsed += parseFloat(b.used_days) || 0; })); + const dRemain = dTotal - dUsed; + const usagePct = dTotal > 0 ? Math.round((dUsed / dTotal) * 100) : 0; + const barColor = usagePct >= 80 ? 'bg-red-400' : usagePct >= 50 ? 'bg-amber-400' : 'bg-emerald-400'; + const collapsed = vacDeptCollapsed[deptName]; + const eid = CSS.escape(deptName); + + html += `
`; + // 헤더 (클릭으로 접기/펼치기) + html += `
+
+ + ${escHtml(deptName)} + ${workerCount}명 +
+
+
+ 배정 ${dTotal} + 사용 ${dUsed} + 잔여 ${dRemain} +
+
+
+
+ ${usagePct}% +
+
`; + // 테이블 본문 + html += `
`; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + Object.values(workers).forEach(g => { + g.items.forEach((b, i) => { + const remaining = parseFloat(b.remaining_days || (b.total_days - b.used_days)); + const remClass = remaining <= 0 ? 'text-red-500 font-semibold' : remaining <= 3 ? 'text-amber-500 font-medium' : 'text-emerald-600'; + html += ``; + if (i === 0) { + html += ``; + html += ``; + } + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + }); + }); + html += '
작업자입사일휴가유형배정사용잔여비고
${escHtml(g.name)}${g.hire_date ? new Date(g.hire_date).toISOString().substring(0,10) : '-'}${escHtml(b.type_name)}${b.total_days}${b.used_days}${remaining}${escHtml(b.notes||'')} + + +
'; + }); + html += '
'; + c.innerHTML = html; +} + +// 자동 계산 +async function autoCalcVacation() { + const year = document.getElementById('vacYear')?.value || new Date().getFullYear(); + if (!confirm(`${year}년 전체 작업자 연차를 입사일 기준으로 자동 계산합니다.\n기존 배정이 있으면 덮어씁니다. 진행하시겠습니까?`)) return; + try { + const r = await api('/vacations/balances/auto-calculate', { method: 'POST', body: JSON.stringify({ year: parseInt(year) }) }); + showToast(`${r.data.count}명 자동 배정 완료`); + await loadVacBalances(); + } catch(e) { showToast(e.message, 'error'); } +} + +// 개별 배정 모달 +function openVacBalanceModal(editId) { + document.getElementById('vbEditId').value = ''; + document.getElementById('vacBalanceForm').reset(); + document.getElementById('vbTotalDays').value = '0'; + document.getElementById('vbUsedDays').value = '0'; + document.getElementById('vacBalModalTitle').textContent = '연차 배정'; + // 작업자 셀렉트 + const wSel = document.getElementById('vbWorker'); + wSel.innerHTML = ''; + vacWorkers.forEach(w => { wSel.innerHTML += ``; }); + // 유형 셀렉트 + const tSel = document.getElementById('vbType'); + tSel.innerHTML = ''; + vacTypes.filter(t => t.is_active).forEach(t => { tSel.innerHTML += ``; }); + if (editId) { + const b = vacBalances.find(x => x.id === editId); + if (!b) return; + document.getElementById('vacBalModalTitle').textContent = '배정 수정'; + document.getElementById('vbEditId').value = b.id; + wSel.value = b.worker_id; + wSel.disabled = true; + tSel.value = b.vacation_type_id; + tSel.disabled = true; + document.getElementById('vbTotalDays').value = b.total_days; + document.getElementById('vbUsedDays').value = b.used_days; + document.getElementById('vbNotes').value = b.notes || ''; + } else { + wSel.disabled = false; + tSel.disabled = false; + } + document.getElementById('vacBalanceModal').classList.remove('hidden'); +} +function closeVacBalanceModal() { + document.getElementById('vacBalanceModal').classList.add('hidden'); + document.getElementById('vbWorker').disabled = false; + document.getElementById('vbType').disabled = false; +} +function editVacBalance(id) { openVacBalanceModal(id); } + +document.getElementById('vacBalanceForm').addEventListener('submit', async e => { + e.preventDefault(); + const editId = document.getElementById('vbEditId').value; + try { + if (editId) { + await api(`/vacations/balances/${editId}`, { method: 'PUT', body: JSON.stringify({ + total_days: parseFloat(document.getElementById('vbTotalDays').value) || 0, + used_days: parseFloat(document.getElementById('vbUsedDays').value) || 0, + notes: document.getElementById('vbNotes').value.trim() || null + })}); + showToast('수정되었습니다.'); + } else { + const year = document.getElementById('vacYear')?.value || new Date().getFullYear(); + await api('/vacations/balances', { method: 'POST', body: JSON.stringify({ + worker_id: parseInt(document.getElementById('vbWorker').value), + vacation_type_id: parseInt(document.getElementById('vbType').value), + year: parseInt(year), + total_days: parseFloat(document.getElementById('vbTotalDays').value) || 0, + used_days: parseFloat(document.getElementById('vbUsedDays').value) || 0, + notes: document.getElementById('vbNotes').value.trim() || null + })}); + showToast('배정되었습니다.'); + } + closeVacBalanceModal(); await loadVacBalances(); + } catch(e) { showToast(e.message, 'error'); } +}); + +async function deleteVacBalance(id) { + if (!confirm('이 배정을 삭제하시겠습니까?')) return; + try { await api(`/vacations/balances/${id}`, { method: 'DELETE' }); showToast('삭제되었습니다.'); await loadVacBalances(); } + catch(e) { showToast(e.message, 'error'); } +} diff --git a/user-management/web/static/js/tkuser-workplaces.js b/user-management/web/static/js/tkuser-workplaces.js new file mode 100644 index 0000000..c898375 --- /dev/null +++ b/user-management/web/static/js/tkuser-workplaces.js @@ -0,0 +1,514 @@ +/* ===== Workplaces CRUD ===== */ +let workplaces = [], workplacesLoaded = false, workplaceCategories = []; +let selectedWorkplaceId = null, selectedWorkplaceName = ''; +let equipments = [], equipmentTypes = []; +let wpNavLevel = 'categories'; // 'categories' | 'workplaces' +let wpNavCategoryId = null; +let wpNavCategoryName = ''; +let previewMapRegions = []; + +function purposeBadge(p) { + const colors = { '작업구역': 'bg-blue-50 text-blue-600', '창고': 'bg-amber-50 text-amber-600', '설비': 'bg-purple-50 text-purple-600', '휴게시설': 'bg-green-50 text-green-600' }; + return p ? `${p}` : ''; +} + +async function loadWorkplaceCategories() { + try { + const r = await api('/workplaces/categories'); workplaceCategories = r.data || r; + populateCategorySelects(); + renderSidebar(); + } catch(e) { console.warn('카테고리 로드 실패:', e); } +} +function populateCategorySelects() { + ['newWorkplaceCategory','editWorkplaceCategory'].forEach(id => { + const sel = document.getElementById(id); if (!sel) return; + const val = sel.value; + sel.innerHTML = ''; + workplaceCategories.forEach(c => { const o = document.createElement('option'); o.value = c.category_id; o.textContent = c.category_name; sel.appendChild(o); }); + sel.value = val; + }); +} + +async function loadWorkplaces() { + await loadWorkplaceCategories(); + try { + const r = await api('/workplaces'); workplaces = r.data || r; + workplacesLoaded = true; + renderSidebar(); + } catch (err) { + document.getElementById('wpSidebarContent').innerHTML = `

${err.message}

`; + } +} + +function renderSidebar() { + const c = document.getElementById('wpSidebarContent'); + if (!c) return; + let html = ''; + if (wpNavLevel === 'categories') { + // 공장 목록 레벨 + html += '
공장 선택
'; + html += ``; + if (!workplaceCategories.length) { + html += '

등록된 공장이 없습니다.

'; + } else { + html += '
'; + workplaceCategories.forEach(cat => { + const count = workplaces.filter(w => w.category_id == cat.category_id).length; + html += `
+
+ + ${cat.category_name} +
+
+ ${count} + +
+
`; + }); + // 미분류 작업장 + const uncategorized = workplaces.filter(w => !w.category_id); + if (uncategorized.length) { + html += `
+
+ + 미분류 +
+
+ ${uncategorized.length} + +
+
`; + } + html += '
'; + } + } else { + // 작업장 목록 레벨 (특정 공장 내) + html += ``; + html += `
${wpNavCategoryName}
`; + html += ``; + const filtered = wpNavCategoryId === 0 + ? workplaces.filter(w => !w.category_id) + : workplaces.filter(w => w.category_id == wpNavCategoryId); + if (!filtered.length) { + html += '

등록된 작업장이 없습니다.

'; + } else { + html += '
'; + filtered.forEach(w => { + html += `
+
+
${w.workplace_name}
+
+ ${purposeBadge(w.workplace_purpose)} + ${w.is_active === 0 || w.is_active === false ? '비활성' : ''} +
+
+
+ + ${w.is_active !== 0 && w.is_active !== false ? `` : ''} +
+
`; + }); + html += '
'; + } + } + c.innerHTML = html; +} + +function drillIntoCategory(categoryId, categoryName) { + wpNavLevel = 'workplaces'; + wpNavCategoryId = categoryId; + wpNavCategoryName = categoryName; + selectedWorkplaceId = null; + renderSidebar(); + showZoneMapForCategory(categoryId); +} + +function backToCategories() { + wpNavLevel = 'categories'; + wpNavCategoryId = null; + wpNavCategoryName = ''; + selectedWorkplaceId = null; + renderSidebar(); + showEmptyState(); +} + +function showEmptyState() { + document.getElementById('workplaceEmptyState')?.classList.remove('hidden'); + document.getElementById('equipmentSection')?.classList.add('hidden'); + document.getElementById('zoneMapSection')?.classList.add('hidden'); +} + +function showZoneMapForCategory(categoryId) { + document.getElementById('workplaceEmptyState')?.classList.add('hidden'); + document.getElementById('equipmentSection')?.classList.add('hidden'); + document.getElementById('zoneMapSection')?.classList.remove('hidden'); + const catName = categoryId === 0 ? '미분류' : (workplaceCategories.find(c => c.category_id == categoryId)?.category_name || ''); + document.getElementById('zoneMapTitle').innerHTML = `${catName} - 구역지도`; + selectedMapCategoryId = categoryId; + if (categoryId === 0) { + document.getElementById('layoutPreviewArea').classList.remove('hidden'); + document.getElementById('layoutPreviewCanvas').classList.add('hidden'); + document.getElementById('layoutPreviewArea').innerHTML = '

미분류 작업장에는 구역지도가 없습니다.

'; + return; + } + loadLayoutPreview(categoryId); +} + +function backToCategory() { + if (!wpNavCategoryId && wpNavCategoryId !== 0) { backToCategories(); return; } + selectedWorkplaceId = null; + renderSidebar(); + showZoneMapForCategory(wpNavCategoryId); +} + +function openAddWorkplaceModal() { + populateCategorySelects(); + document.getElementById('addWorkplaceForm').reset(); + // 공장 드릴다운 상태이면 카테고리 자동 선택 + if (wpNavLevel === 'workplaces' && wpNavCategoryId && wpNavCategoryId !== 0) { + document.getElementById('newWorkplaceCategory').value = wpNavCategoryId; + } + document.getElementById('addWorkplaceModal').classList.remove('hidden'); +} +function closeAddWorkplaceModal() { document.getElementById('addWorkplaceModal').classList.add('hidden'); } + +document.getElementById('addWorkplaceForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api('/workplaces', { method: 'POST', body: JSON.stringify({ + workplace_name: document.getElementById('newWorkplaceName').value.trim(), + category_id: document.getElementById('newWorkplaceCategory').value ? parseInt(document.getElementById('newWorkplaceCategory').value) : null, + workplace_purpose: document.getElementById('newWorkplacePurpose').value || null, + description: document.getElementById('newWorkplaceDesc').value.trim() || null, + display_priority: parseInt(document.getElementById('newWorkplacePriority').value) || 0 + })}); + showToast('작업장이 추가되었습니다.'); document.getElementById('addWorkplaceForm').reset(); closeAddWorkplaceModal(); await loadWorkplaces(); + } catch(e) { showToast(e.message, 'error'); } +}); + +function editWorkplace(id) { + const w = workplaces.find(x => x.workplace_id === id); if (!w) return; + document.getElementById('editWorkplaceId').value = w.workplace_id; + document.getElementById('editWorkplaceName').value = w.workplace_name; + document.getElementById('editWorkplaceDesc').value = w.description || ''; + document.getElementById('editWorkplacePriority').value = w.display_priority || 0; + document.getElementById('editWorkplaceActive').value = (w.is_active === 0 || w.is_active === false) ? '0' : '1'; + document.getElementById('editWorkplacePurpose').value = w.workplace_purpose || ''; + populateCategorySelects(); + document.getElementById('editWorkplaceCategory').value = w.category_id || ''; + document.getElementById('editWorkplaceModal').classList.remove('hidden'); +} +function closeWorkplaceModal() { document.getElementById('editWorkplaceModal').classList.add('hidden'); } + +document.getElementById('editWorkplaceForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api(`/workplaces/${document.getElementById('editWorkplaceId').value}`, { method: 'PUT', body: JSON.stringify({ + workplace_name: document.getElementById('editWorkplaceName').value.trim(), + category_id: document.getElementById('editWorkplaceCategory').value ? parseInt(document.getElementById('editWorkplaceCategory').value) : null, + workplace_purpose: document.getElementById('editWorkplacePurpose').value || null, + description: document.getElementById('editWorkplaceDesc').value.trim() || null, + display_priority: parseInt(document.getElementById('editWorkplacePriority').value) || 0, + is_active: document.getElementById('editWorkplaceActive').value === '1' + })}); + showToast('수정되었습니다.'); closeWorkplaceModal(); await loadWorkplaces(); + } catch(e) { showToast(e.message, 'error'); } +}); + +async function deactivateWorkplace(id, name) { + if (!confirm(`"${name}" 작업장을 비활성화?`)) return; + try { await api(`/workplaces/${id}`, { method: 'DELETE' }); showToast('작업장 비활성화 완료'); await loadWorkplaces(); } catch(e) { showToast(e.message, 'error'); } +} + +/* ===== Equipment CRUD ===== */ +let eqMapImg = null, eqMapCanvas = null, eqMapCtx = null, eqDetailEqId = null; + +function eqStatusBadge(status) { + const map = { active:'bg-emerald-50 text-emerald-600', maintenance:'bg-amber-50 text-amber-600', inactive:'bg-gray-100 text-gray-500', external:'bg-blue-50 text-blue-600', repair_external:'bg-blue-50 text-blue-600', repair_needed:'bg-red-50 text-red-600' }; + const labels = { active:'가동중', maintenance:'점검중', inactive:'비활성', external:'외부반출', repair_external:'수리외주', repair_needed:'수리필요' }; + return `${labels[status] || status || ''}`; +} + +function selectWorkplaceForEquipments(id, name) { + selectedWorkplaceId = id; + selectedWorkplaceName = name; + // 카테고리 레벨에서 직접 호출된 경우, 해당 카테고리로 드릴인 + if (wpNavLevel === 'categories') { + const wp = workplaces.find(w => w.workplace_id === id); + if (wp && wp.category_id) { + wpNavLevel = 'workplaces'; + wpNavCategoryId = wp.category_id; + wpNavCategoryName = wp.category_name || ''; + } + } + renderSidebar(); + document.getElementById('workplaceEmptyState')?.classList.add('hidden'); + document.getElementById('zoneMapSection')?.classList.add('hidden'); + document.getElementById('equipmentSection').classList.remove('hidden'); + document.getElementById('eqWorkplaceName').textContent = name; + // 뒤로가기 버튼 표시 (공장 구역지도로 돌아가기) + const backBtn = document.getElementById('eqBackToCategory'); + if (backBtn && wpNavCategoryId !== null) { + document.getElementById('eqBackLabel').textContent = `${wpNavCategoryName} 구역지도`; + backBtn.classList.remove('hidden'); + } else if (backBtn) { + backBtn.classList.add('hidden'); + } + loadEquipments(); + loadEquipmentTypes(); + loadEqMap(); +} + +async function loadEquipments() { + try { + const r = await api(`/equipments/workplace/${selectedWorkplaceId}`); + equipments = r.data || []; + displayEquipments(); + drawEqMapEquipments(); + } catch(e) { + document.getElementById('equipmentList').innerHTML = `

${e.message}

`; + } +} + +function displayEquipments() { + const statusFilter = document.getElementById('eqStatusFilter').value; + const typeFilter = document.getElementById('eqTypeFilter').value; + let filtered = equipments; + if (statusFilter) filtered = filtered.filter(e => e.status === statusFilter); + if (typeFilter) filtered = filtered.filter(e => e.equipment_type === typeFilter); + const c = document.getElementById('equipmentList'); + if (!filtered.length) { c.innerHTML = '

설비가 없습니다.

'; return; } + c.innerHTML = filtered.map(e => ` +
+
+
+ ${e.equipment_code || ''}${e.equipment_name} + ${e.is_temporarily_moved ? '' : ''} +
+
+ ${e.equipment_type ? `${e.equipment_type}` : ''} + ${e.manufacturer ? `${e.manufacturer}` : ''} + ${eqStatusBadge(e.status)} +
+
+
+ + +
+
`).join(''); +} + +function filterEquipments() { displayEquipments(); } + +async function loadEquipmentTypes() { + try { + const r = await api('/equipments/types'); equipmentTypes = r.data || []; + const sel = document.getElementById('eqTypeFilter'); const val = sel.value; + sel.innerHTML = ''; + equipmentTypes.forEach(t => { const o = document.createElement('option'); o.value = t; o.textContent = t; sel.appendChild(o); }); + sel.value = val; + const dl = document.getElementById('eqTypeDatalist'); + if (dl) { dl.innerHTML = ''; equipmentTypes.forEach(t => { const o = document.createElement('option'); o.value = t; dl.appendChild(o); }); } + } catch(e) { console.warn('설비 유형 로드 실패:', e); } +} + +async function openEquipmentModal(editId) { + document.getElementById('eqEditId').value = ''; + document.getElementById('equipmentForm').reset(); + if (editId) { + document.getElementById('eqModalTitle').textContent = '설비 수정'; + const eq = equipments.find(e => e.equipment_id === editId); + if (!eq) return; + document.getElementById('eqEditId').value = eq.equipment_id; + document.getElementById('eqCode').value = eq.equipment_code || ''; + document.getElementById('eqName').value = eq.equipment_name || ''; + document.getElementById('eqType').value = eq.equipment_type || ''; + document.getElementById('eqStatus').value = eq.status || 'active'; + document.getElementById('eqManufacturer').value = eq.manufacturer || ''; + document.getElementById('eqModel').value = eq.model_name || ''; + document.getElementById('eqSupplier').value = eq.supplier || ''; + document.getElementById('eqPrice').value = eq.purchase_price || ''; + document.getElementById('eqInstallDate').value = eq.installation_date ? eq.installation_date.substring(0, 10) : ''; + document.getElementById('eqSerial').value = eq.serial_number || ''; + document.getElementById('eqSpecs').value = eq.specifications || ''; + document.getElementById('eqNotes').value = eq.notes || ''; + } else { + document.getElementById('eqModalTitle').textContent = '설비 추가'; + generateEquipmentCode(); + } + document.getElementById('equipmentModal').classList.remove('hidden'); +} +function closeEquipmentModal() { document.getElementById('equipmentModal').classList.add('hidden'); } +async function generateEquipmentCode() { try { const r = await api('/equipments/next-code?prefix=TKP'); document.getElementById('eqCode').value = r.data || ''; } catch(e) {} } +function editEquipment(id) { openEquipmentModal(id); } +async function deleteEquipment(id, name) { + if (!confirm(`"${name}" 설비를 삭제하시겠습니까?`)) return; + try { await api(`/equipments/${id}`, { method: 'DELETE' }); showToast('설비가 삭제되었습니다.'); await loadEquipments(); } catch(e) { showToast(e.message, 'error'); } +} + +document.getElementById('equipmentForm').addEventListener('submit', async e => { + e.preventDefault(); + const editId = document.getElementById('eqEditId').value; + const body = { + equipment_code: document.getElementById('eqCode').value.trim(), + equipment_name: document.getElementById('eqName').value.trim(), + equipment_type: document.getElementById('eqType').value.trim() || null, + status: document.getElementById('eqStatus').value, + manufacturer: document.getElementById('eqManufacturer').value.trim() || null, + model_name: document.getElementById('eqModel').value.trim() || null, + supplier: document.getElementById('eqSupplier').value.trim() || null, + purchase_price: document.getElementById('eqPrice').value ? parseFloat(document.getElementById('eqPrice').value) : null, + installation_date: document.getElementById('eqInstallDate').value || null, + serial_number: document.getElementById('eqSerial').value.trim() || null, + specifications: document.getElementById('eqSpecs').value.trim() || null, + notes: document.getElementById('eqNotes').value.trim() || null, + workplace_id: selectedWorkplaceId + }; + try { + if (editId) { await api(`/equipments/${editId}`, { method: 'PUT', body: JSON.stringify(body) }); showToast('설비가 수정되었습니다.'); } + else { await api('/equipments', { method: 'POST', body: JSON.stringify(body) }); showToast('설비가 추가되었습니다.'); } + closeEquipmentModal(); await loadEquipments(); loadEquipmentTypes(); + } catch(e) { showToast(e.message, 'error'); } +}); + +/* ===== Equipment Map (설비 배치도) ===== */ +function loadEqMap() { + const wp = workplaces.find(w => w.workplace_id === selectedWorkplaceId); + if (!wp || !wp.layout_image) { + document.getElementById('eqMapArea').classList.remove('hidden'); + document.getElementById('eqMapCanvasWrap').classList.add('hidden'); + return; + } + document.getElementById('eqMapArea').classList.add('hidden'); + document.getElementById('eqMapCanvasWrap').classList.remove('hidden'); + eqMapCanvas = document.getElementById('eqMapCanvas'); + eqMapCtx = eqMapCanvas.getContext('2d'); + const imgUrl = wp.layout_image.startsWith('/') ? '/uploads/' + wp.layout_image.replace(/^\/uploads\//, '') : wp.layout_image; + const img = new Image(); + img.onload = function() { + const maxW = 780; const scale = img.width > maxW ? maxW / img.width : 1; + eqMapCanvas.width = img.width * scale; eqMapCanvas.height = img.height * scale; + eqMapCtx.drawImage(img, 0, 0, eqMapCanvas.width, eqMapCanvas.height); + eqMapImg = img; + drawEqMapEquipments(); + eqMapCanvas.onclick = onEqMapClick; + }; + img.src = imgUrl; +} + +function drawEqMapEquipments() { + if (!eqMapCanvas || !eqMapCtx || !eqMapImg) return; + eqMapCtx.clearRect(0, 0, eqMapCanvas.width, eqMapCanvas.height); + eqMapCtx.drawImage(eqMapImg, 0, 0, eqMapCanvas.width, eqMapCanvas.height); + equipments.forEach(eq => { + if (eq.map_x_percent == null || eq.map_y_percent == null) return; + const x = (eq.map_x_percent / 100) * eqMapCanvas.width; + const y = (eq.map_y_percent / 100) * eqMapCanvas.height; + const w = ((eq.map_width_percent || 3) / 100) * eqMapCanvas.width; + const h = ((eq.map_height_percent || 3) / 100) * eqMapCanvas.height; + const colors = { active:'#10b981', maintenance:'#f59e0b', inactive:'#94a3b8', external:'#3b82f6', repair_external:'#3b82f6', repair_needed:'#ef4444' }; + const color = colors[eq.status] || '#64748b'; + eqMapCtx.fillStyle = color + '33'; eqMapCtx.fillRect(x - w/2, y - h/2, w, h); + eqMapCtx.strokeStyle = color; eqMapCtx.lineWidth = 2; eqMapCtx.strokeRect(x - w/2, y - h/2, w, h); + eqMapCtx.fillStyle = color; eqMapCtx.font = 'bold 10px sans-serif'; eqMapCtx.textAlign = 'center'; + eqMapCtx.fillText(eq.equipment_code || eq.equipment_name, x, y - h/2 - 3); + eqMapCtx.textAlign = 'start'; + }); +} + +let eqMapPlacingId = null; +function onEqMapClick(e) { + if (!eqMapPlacingId) return; + const r = eqMapCanvas.getBoundingClientRect(); + const scaleX = eqMapCanvas.width / r.width; const scaleY = eqMapCanvas.height / r.height; + const px = (e.clientX - r.left) * scaleX; const py = (e.clientY - r.top) * scaleY; + const xPct = (px / eqMapCanvas.width * 100).toFixed(2); const yPct = (py / eqMapCanvas.height * 100).toFixed(2); + saveEqMapPosition(eqMapPlacingId, xPct, yPct); + eqMapPlacingId = null; eqMapCanvas.style.cursor = 'default'; +} + +async function saveEqMapPosition(eqId, x, y) { + try { + await api(`/equipments/${eqId}/map-position`, { method: 'PATCH', body: JSON.stringify({ map_x_percent: parseFloat(x), map_y_percent: parseFloat(y), map_width_percent: 3, map_height_percent: 3 }) }); + showToast('설비 위치가 저장되었습니다.'); await loadEquipments(); + } catch(e) { showToast(e.message, 'error'); } +} + +function startPlaceEquipment(eqId) { + eqMapPlacingId = eqId; if(eqMapCanvas) eqMapCanvas.style.cursor = 'crosshair'; + showToast('배치도에서 위치를 클릭하세요'); +} + +async function uploadWorkplaceLayoutImage() { + const file = document.getElementById('wpLayoutImageFile').files[0]; + if (!file) return; + try { + const fd = new FormData(); fd.append('image', file); const token = getToken(); + const res = await fetch(`${API_BASE}/workplaces/${selectedWorkplaceId}/layout-image`, { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '' }, body: fd }); + const result = await res.json(); if (!res.ok) throw new Error(result.error || '업로드 실패'); + showToast('배치도 이미지가 업로드되었습니다.'); + const wp = workplaces.find(w => w.workplace_id === selectedWorkplaceId); + if (wp) wp.layout_image = result.data.image_path; + loadEqMap(); + } catch(e) { showToast(e.message || '업로드 실패', 'error'); } +} + +/* ===== Equipment Detail Modal ===== */ +async function openEqDetailModal(eqId) { + eqDetailEqId = eqId; + const eq = equipments.find(e => e.equipment_id === eqId); + if (!eq) return; + document.getElementById('eqDetailTitle').textContent = `${eq.equipment_code} - ${eq.equipment_name}`; + document.getElementById('eqReturnBtn').classList.toggle('hidden', !eq.is_temporarily_moved); + const fmt = v => v || '-'; + const fmtDate = v => v ? v.substring(0, 10) : '-'; + const fmtPrice = v => v ? Number(v).toLocaleString() + '원' : '-'; + document.getElementById('eqDetailContent').innerHTML = ` +
+
유형: ${fmt(eq.equipment_type)}
+
상태: ${eqStatusBadge(eq.status)}
+
제조사: ${fmt(eq.manufacturer)}
+
모델: ${fmt(eq.model_name)}
+
공급업체: ${fmt(eq.supplier)}
+
구매가격: ${fmtPrice(eq.purchase_price)}
+
설치일: ${fmtDate(eq.installation_date)}
+
시리얼: ${fmt(eq.serial_number)}
+
사양: ${fmt(eq.specifications)}
+
비고: ${fmt(eq.notes)}
+
+
`; + loadEqPhotos(eqId); + document.getElementById('eqDetailModal').classList.remove('hidden'); +} +function closeEqDetailModal() { document.getElementById('eqDetailModal').classList.add('hidden'); } + +async function loadEqPhotos(eqId) { + const c = document.getElementById('eqPhotoGrid'); + try { + const r = await api(`/equipments/${eqId}/photos`); const photos = r.data || []; + if (!photos.length) { c.innerHTML = '

사진 없음

'; return; } + c.innerHTML = photos.map(p => { + const fname = (p.photo_path||'').replace(/^\/uploads\//, ''); + return ` +
+ + +
`; }).join(''); + } catch(e) { c.innerHTML = '

로드 실패

'; } +} + +async function uploadEqPhoto() { + const file = document.getElementById('eqPhotoFile').files[0]; if (!file || !eqDetailEqId) return; + try { + const fd = new FormData(); fd.append('photo', file); const token = getToken(); + const res = await fetch(`${API_BASE}/equipments/${eqDetailEqId}/photos`, { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '' }, body: fd }); + const result = await res.json(); if (!res.ok) throw new Error(result.error || '업로드 실패'); + showToast('사진이 추가되었습니다.'); loadEqPhotos(eqDetailEqId); + } catch(e) { showToast(e.message, 'error'); } + document.getElementById('eqPhotoFile').value = ''; +} + +async function deleteEqPhoto(photoId) { + if (!confirm('사진을 삭제하시겠습니까?')) return; + try { await api(`/equipments/photos/${photoId}`, { method: 'DELETE' }); showToast('삭제됨'); loadEqPhotos(eqDetailEqId); } catch(e) { showToast(e.message, 'error'); } +}