security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거
보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축. [보안 수정] - issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성 - pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD - DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder - docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가 [보안 강제 시스템 - 신규] - scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2) 3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값 - .githooks/pre-commit: 로컬 빠른 피드백 - .githooks/pre-receive-server.sh: Gitea 서버 최종 차단 bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그) - SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분 - docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
176
system3-nonconformance/web/public/static/css/ai-assistant.css
Normal file
176
system3-nonconformance/web/public/static/css/ai-assistant.css
Normal file
@@ -0,0 +1,176 @@
|
||||
/* ai-assistant.css — AI 어시스턴트 페이지 전용 스타일 */
|
||||
|
||||
/* 페이드인 애니메이션 */
|
||||
.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); } }
|
||||
|
||||
/* 채팅 컨테이너 */
|
||||
.chat-container {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* 채팅 말풍선 */
|
||||
.chat-bubble {
|
||||
max-width: 85%;
|
||||
animation: bubbleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes bubbleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-bubble-user {
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
padding: 10px 16px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chat-bubble-ai {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
/* AI 답변 마크다운 스타일 */
|
||||
.chat-bubble-ai .prose { color: #1e293b; }
|
||||
.chat-bubble-ai .prose h1,
|
||||
.chat-bubble-ai .prose h2,
|
||||
.chat-bubble-ai .prose h3 { font-size: 0.95em; font-weight: 700; margin: 0.8em 0 0.3em; color: #334155; }
|
||||
.chat-bubble-ai .prose p { margin: 0.4em 0; }
|
||||
.chat-bubble-ai .prose ul,
|
||||
.chat-bubble-ai .prose ol { margin: 0.3em 0; padding-left: 1.4em; }
|
||||
.chat-bubble-ai .prose li { margin: 0.15em 0; }
|
||||
.chat-bubble-ai .prose strong { color: #7c3aed; }
|
||||
.chat-bubble-ai .prose code { background: #e2e8f0; padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||
.chat-bubble-ai .prose blockquote { border-left: 3px solid #7c3aed; padding-left: 0.8em; margin: 0.5em 0; color: #64748b; }
|
||||
.chat-bubble-ai .prose hr { border-color: #e2e8f0; margin: 0.6em 0; }
|
||||
|
||||
.chat-bubble-ai .source-link {
|
||||
color: #7c3aed;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.chat-bubble-ai .source-link:hover {
|
||||
color: #6d28d9;
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
/* 로딩 도트 애니메이션 */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #94a3b8;
|
||||
border-radius: 50%;
|
||||
animation: typingBounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 빠른 질문 버튼 */
|
||||
.quick-question-btn {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.quick-question-btn:hover {
|
||||
border-color: #7c3aed;
|
||||
background: #faf5ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
|
||||
/* 상태 카드 */
|
||||
.status-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 섹션 카드 */
|
||||
.section-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 결과 아이템 */
|
||||
.result-item {
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
/* 모바일 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 92%;
|
||||
}
|
||||
|
||||
button, a, [onclick], select {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom)) !important;
|
||||
}
|
||||
}
|
||||
49
system3-nonconformance/web/public/static/css/issue-view.css
Normal file
49
system3-nonconformance/web/public/static/css/issue-view.css
Normal file
@@ -0,0 +1,49 @@
|
||||
/* issue-view.css — 부적합 조회 페이지 전용 스타일 */
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #6b7280;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* issues-inbox.css — 수신함 페이지 전용 스타일 */
|
||||
|
||||
.issue-card.unread {
|
||||
border-left-color: #3b82f6;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* 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: 5000px;
|
||||
overflow: visible;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
|
||||
.collapse-content.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 관리함 전용 이슈 카드 오버라이드 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ===== 카드 헤더 반응형 ===== */
|
||||
.issue-card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.issue-card-header .header-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.issue-card-header .header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.issue-card-header .header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.issue-card-header .header-actions button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 중간 화면에서 버튼 줄바꿈 */
|
||||
@media (max-width: 1280px) {
|
||||
.issue-card-header .header-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.issue-card-header .header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* 완료됨 카드 3열 → 좁은 화면에서 적응 */
|
||||
@media (max-width: 1280px) and (min-width: 769px) {
|
||||
.completed-card-grid {
|
||||
grid-template-columns: 1fr 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) and (min-width: 769px) {
|
||||
.completed-card-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
489
system3-nonconformance/web/public/static/css/m-common.css
Normal file
489
system3-nonconformance/web/public/static/css/m-common.css
Normal file
@@ -0,0 +1,489 @@
|
||||
/* m-common.css — TKQC 모바일 공통 스타일 */
|
||||
|
||||
/* ===== Reset & Base ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
padding-top: 48px;
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom));
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
input, select, textarea, button { font-family: inherit; font-size: 16px; touch-action: manipulation; }
|
||||
button { cursor: pointer; border: none; background: none; padding: 0; }
|
||||
|
||||
/* ===== Fixed Header ===== */
|
||||
.m-header {
|
||||
position: fixed; top: 0; left: 0; right: 0;
|
||||
height: 48px; z-index: 100;
|
||||
background: #fff; border-bottom: 1px solid #e5e7eb;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.m-header-title {
|
||||
font-size: 17px; font-weight: 700; color: #111827;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.m-header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.m-header-btn {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #6b7280; transition: background 0.15s;
|
||||
}
|
||||
.m-header-btn:active { background: #f3f4f6; }
|
||||
|
||||
/* ===== Bottom Navigation ===== */
|
||||
.m-bottom-nav {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
height: calc(64px + env(safe-area-inset-bottom));
|
||||
background: #fff; border-top: 1px solid #e5e7eb;
|
||||
display: flex; z-index: 100;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
.m-nav-item {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
text-decoration: none; color: #9ca3af;
|
||||
font-size: 10px; font-weight: 500; gap: 2px;
|
||||
position: relative; min-height: 44px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.m-nav-item i { font-size: 20px; }
|
||||
.m-nav-item.active { color: #2563eb; font-weight: 700; }
|
||||
.m-nav-item.active::before {
|
||||
content: ''; position: absolute; top: 2px;
|
||||
width: 4px; height: 4px; border-radius: 50%; background: #2563eb;
|
||||
}
|
||||
.m-nav-item.highlight { color: #f97316; }
|
||||
.m-nav-item.highlight.active { color: #f97316; }
|
||||
.m-nav-item:active { opacity: 0.7; }
|
||||
|
||||
/* ===== Stats Bar ===== */
|
||||
.m-stats-bar {
|
||||
display: flex; gap: 8px; padding: 12px 16px;
|
||||
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.m-stats-bar::-webkit-scrollbar { display: none; }
|
||||
.m-stat-pill {
|
||||
flex-shrink: 0; padding: 8px 14px; border-radius: 20px;
|
||||
background: #fff; border: 1px solid #e5e7eb;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; font-weight: 500; color: #6b7280;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
.m-stat-pill .m-stat-value {
|
||||
font-weight: 700; font-size: 15px; color: #111827;
|
||||
}
|
||||
.m-stat-pill.blue { border-color: #93c5fd; background: #eff6ff; }
|
||||
.m-stat-pill.blue .m-stat-value { color: #2563eb; }
|
||||
.m-stat-pill.green { border-color: #86efac; background: #f0fdf4; }
|
||||
.m-stat-pill.green .m-stat-value { color: #16a34a; }
|
||||
.m-stat-pill.amber { border-color: #fcd34d; background: #fffbeb; }
|
||||
.m-stat-pill.amber .m-stat-value { color: #d97706; }
|
||||
.m-stat-pill.red { border-color: #fca5a5; background: #fef2f2; }
|
||||
.m-stat-pill.red .m-stat-value { color: #dc2626; }
|
||||
.m-stat-pill.purple { border-color: #c4b5fd; background: #f5f3ff; }
|
||||
.m-stat-pill.purple .m-stat-value { color: #7c3aed; }
|
||||
.m-stat-pill.slate { border-color: #cbd5e1; background: #f8fafc; }
|
||||
.m-stat-pill.slate .m-stat-value { color: #475569; }
|
||||
|
||||
/* ===== Project Filter ===== */
|
||||
.m-filter-bar {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
.m-filter-select {
|
||||
width: 100%; padding: 10px 12px; border-radius: 10px;
|
||||
border: 1px solid #d1d5db; background: #fff;
|
||||
font-size: 14px; color: #374151;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 12px center;
|
||||
}
|
||||
|
||||
/* ===== Tab Bar ===== */
|
||||
.m-tab-bar {
|
||||
display: flex; background: #fff; border-bottom: 1px solid #e5e7eb;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.m-tab {
|
||||
flex: 1; text-align: center; padding: 12px 0;
|
||||
font-size: 14px; font-weight: 600; color: #9ca3af;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
.m-tab.active { color: #2563eb; border-bottom-color: #2563eb; }
|
||||
.m-tab:active { opacity: 0.7; }
|
||||
|
||||
/* ===== Date Group ===== */
|
||||
.m-date-group { padding: 0 16px; margin-bottom: 8px; }
|
||||
.m-date-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 0; font-size: 13px; font-weight: 600; color: #6b7280;
|
||||
}
|
||||
.m-date-header i { font-size: 10px; color: #9ca3af; transition: transform 0.2s; }
|
||||
.m-date-header .m-date-count {
|
||||
font-size: 12px; font-weight: 400; color: #9ca3af;
|
||||
}
|
||||
.m-date-header.collapsed i { transform: rotate(-90deg); }
|
||||
|
||||
/* ===== Issue Card ===== */
|
||||
.m-card {
|
||||
background: #fff; border-radius: 12px; margin: 0 16px 10px;
|
||||
border: 1px solid #e5e7eb; overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.m-card:active { transform: scale(0.985); }
|
||||
.m-card-header {
|
||||
padding: 12px 14px 8px; display: flex; align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.m-card-no {
|
||||
font-size: 15px; font-weight: 800; color: #2563eb;
|
||||
}
|
||||
.m-card-project {
|
||||
font-size: 12px; color: #6b7280; margin-left: 8px;
|
||||
}
|
||||
.m-card-title {
|
||||
padding: 0 14px 8px; font-size: 15px; font-weight: 600; color: #111827;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-card-body { padding: 0 14px 12px; }
|
||||
.m-card-footer {
|
||||
padding: 8px 14px; border-top: 1px solid #f3f4f6;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 12px; color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Card border accents */
|
||||
.m-card.border-blue { border-left: 4px solid #3b82f6; }
|
||||
.m-card.border-green { border-left: 4px solid #22c55e; }
|
||||
.m-card.border-red { border-left: 4px solid #ef4444; }
|
||||
.m-card.border-purple { border-left: 4px solid #8b5cf6; }
|
||||
.m-card.border-amber { border-left: 4px solid #f59e0b; }
|
||||
|
||||
/* ===== Status Badge ===== */
|
||||
.m-badge {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 3px 8px; border-radius: 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.m-badge.in-progress { background: #dbeafe; color: #1d4ed8; }
|
||||
.m-badge.pending-completion { background: #ede9fe; color: #6d28d9; }
|
||||
.m-badge.overdue { background: #fee2e2; color: #b91c1c; }
|
||||
.m-badge.completed { background: #dcfce7; color: #15803d; }
|
||||
.m-badge.review { background: #dbeafe; color: #1d4ed8; }
|
||||
.m-badge.urgent { background: #ffedd5; color: #c2410c; }
|
||||
|
||||
/* ===== Photo Thumbnails ===== */
|
||||
.m-photo-row {
|
||||
display: flex; gap: 6px; overflow-x: auto;
|
||||
scrollbar-width: none; padding: 4px 0;
|
||||
}
|
||||
.m-photo-row::-webkit-scrollbar { display: none; }
|
||||
.m-photo-thumb {
|
||||
width: 60px; height: 60px; flex-shrink: 0; border-radius: 8px;
|
||||
object-fit: cover; border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* ===== Action Buttons ===== */
|
||||
.m-action-row {
|
||||
display: flex; gap: 8px; padding: 8px 14px 12px;
|
||||
}
|
||||
.m-action-btn {
|
||||
flex: 1; padding: 10px 0; border-radius: 10px;
|
||||
font-size: 13px; font-weight: 600; color: #fff;
|
||||
text-align: center; min-height: 44px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.m-action-btn:active { opacity: 0.8; }
|
||||
.m-action-btn.red { background: #ef4444; }
|
||||
.m-action-btn.blue { background: #3b82f6; }
|
||||
.m-action-btn.green { background: #22c55e; }
|
||||
.m-action-btn.purple { background: #8b5cf6; }
|
||||
|
||||
/* ===== Bottom Sheet ===== */
|
||||
.m-sheet-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||||
z-index: 200; opacity: 0; visibility: hidden;
|
||||
transition: opacity 0.25s, visibility 0.25s;
|
||||
}
|
||||
.m-sheet-overlay.open { opacity: 1; visibility: visible; }
|
||||
|
||||
.m-sheet {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
z-index: 201; background: #fff;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
.m-sheet.open { transform: translateY(0); }
|
||||
|
||||
.m-sheet-handle {
|
||||
width: 36px; height: 4px; border-radius: 2px;
|
||||
background: #d1d5db; margin: 10px auto 4px;
|
||||
}
|
||||
.m-sheet-header {
|
||||
position: sticky; top: 0; background: #fff;
|
||||
padding: 8px 16px 12px; z-index: 1;
|
||||
border-radius: 16px 16px 0 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.m-sheet-title { font-size: 17px; font-weight: 700; color: #111827; }
|
||||
.m-sheet-close {
|
||||
width: 32px; height: 32px; border-radius: 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #9ca3af; font-size: 18px; background: #f3f4f6;
|
||||
}
|
||||
.m-sheet-close:active { background: #e5e7eb; }
|
||||
.m-sheet-body { padding: 16px; }
|
||||
.m-sheet-footer {
|
||||
position: sticky; bottom: 0; background: #fff;
|
||||
padding: 12px 16px; border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* ===== Form Inputs ===== */
|
||||
.m-input, .m-select, .m-textarea {
|
||||
width: 100%; padding: 12px; border-radius: 10px;
|
||||
border: 1px solid #d1d5db; background: #fff;
|
||||
font-size: 16px; color: #111827;
|
||||
min-height: 44px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.m-input:focus, .m-select:focus, .m-textarea:focus {
|
||||
outline: none; border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||
}
|
||||
.m-textarea { resize: vertical; min-height: 88px; }
|
||||
.m-label {
|
||||
display: block; font-size: 13px; font-weight: 600;
|
||||
color: #374151; margin-bottom: 6px;
|
||||
}
|
||||
.m-form-group { margin-bottom: 16px; }
|
||||
|
||||
/* ===== Submit Button ===== */
|
||||
.m-submit-btn {
|
||||
width: 100%; padding: 14px; border-radius: 12px;
|
||||
font-size: 15px; font-weight: 700; color: #fff;
|
||||
background: #3b82f6; min-height: 48px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.m-submit-btn:active { opacity: 0.8; }
|
||||
.m-submit-btn:disabled { opacity: 0.5; }
|
||||
.m-submit-btn.green { background: #22c55e; }
|
||||
.m-submit-btn.red { background: #ef4444; }
|
||||
|
||||
/* ===== Toast ===== */
|
||||
.m-toast {
|
||||
position: fixed; bottom: calc(80px + env(safe-area-inset-bottom));
|
||||
left: 50%; transform: translateX(-50%) translateY(20px);
|
||||
padding: 12px 20px; border-radius: 12px;
|
||||
font-size: 14px; font-weight: 500; color: #fff;
|
||||
background: #1f2937; z-index: 300;
|
||||
opacity: 0; transition: opacity 0.3s, transform 0.3s;
|
||||
max-width: calc(100vw - 32px); text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.m-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
.m-toast.success { background: #16a34a; }
|
||||
.m-toast.error { background: #dc2626; }
|
||||
.m-toast.warning { background: #d97706; }
|
||||
|
||||
/* ===== Loading ===== */
|
||||
.m-loading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 40px; color: #9ca3af;
|
||||
}
|
||||
.m-loading .m-spinner {
|
||||
width: 28px; height: 28px; border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6; border-radius: 50%;
|
||||
animation: m-spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes m-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.m-loading-overlay {
|
||||
position: fixed; inset: 0; background: #fff; z-index: 150;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.m-loading-overlay.hide { opacity: 0; pointer-events: none; }
|
||||
.m-loading-overlay .m-spinner { width: 36px; height: 36px; }
|
||||
.m-loading-overlay p { margin-top: 12px; color: #6b7280; font-size: 14px; }
|
||||
|
||||
/* ===== Empty State ===== */
|
||||
.m-empty {
|
||||
text-align: center; padding: 60px 20px; color: #9ca3af;
|
||||
}
|
||||
.m-empty i { font-size: 48px; margin-bottom: 12px; display: block; }
|
||||
.m-empty p { font-size: 14px; line-height: 1.5; }
|
||||
|
||||
/* ===== Opinion / Comment Section ===== */
|
||||
.m-opinions-toggle {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; border-radius: 12px;
|
||||
background: #f3f4f6; font-size: 12px; font-weight: 600;
|
||||
color: #6b7280;
|
||||
}
|
||||
.m-opinions-toggle:active { background: #e5e7eb; }
|
||||
.m-opinion-card {
|
||||
padding: 10px; margin: 6px 0; border-radius: 10px;
|
||||
border-left: 3px solid #22c55e; background: #f0fdf4;
|
||||
}
|
||||
.m-opinion-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-bottom: 4px; font-size: 12px;
|
||||
}
|
||||
.m-opinion-avatar {
|
||||
width: 22px; height: 22px; border-radius: 11px;
|
||||
background: #3b82f6; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.m-opinion-author { font-weight: 600; color: #111827; }
|
||||
.m-opinion-time { color: #9ca3af; }
|
||||
.m-opinion-text { font-size: 13px; color: #374151; line-height: 1.5; padding-left: 28px; white-space: pre-wrap; }
|
||||
|
||||
.m-opinion-actions {
|
||||
display: flex; gap: 6px; padding-left: 28px; margin-top: 6px;
|
||||
}
|
||||
.m-opinion-action-btn {
|
||||
padding: 3px 8px; border-radius: 6px;
|
||||
font-size: 11px; font-weight: 500;
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
}
|
||||
.m-opinion-action-btn.comment-btn { background: #dbeafe; color: #1d4ed8; }
|
||||
.m-opinion-action-btn.edit-btn { background: #dcfce7; color: #15803d; }
|
||||
.m-opinion-action-btn.delete-btn { background: #fee2e2; color: #b91c1c; }
|
||||
.m-opinion-action-btn.reply-btn { background: #dbeafe; color: #1d4ed8; }
|
||||
|
||||
/* Comment */
|
||||
.m-comment {
|
||||
margin: 4px 0 4px 28px; padding: 8px 10px; border-radius: 8px;
|
||||
background: #fff; border: 1px solid #e5e7eb; font-size: 12px;
|
||||
}
|
||||
.m-comment-header {
|
||||
display: flex; align-items: center; gap: 4px; margin-bottom: 2px;
|
||||
}
|
||||
.m-comment-avatar {
|
||||
width: 18px; height: 18px; border-radius: 9px;
|
||||
background: #9ca3af; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 8px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.m-comment-text { color: #374151; padding-left: 22px; }
|
||||
|
||||
/* Reply */
|
||||
.m-reply {
|
||||
margin: 3px 0 3px 50px; padding: 6px 8px; border-radius: 6px;
|
||||
background: #eff6ff; font-size: 11px;
|
||||
border-left: 2px solid #93c5fd;
|
||||
}
|
||||
.m-reply-header {
|
||||
display: flex; align-items: center; gap: 3px; margin-bottom: 1px;
|
||||
}
|
||||
.m-reply-text { color: #374151; padding-left: 0; }
|
||||
|
||||
/* ===== Info Row ===== */
|
||||
.m-info-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; color: #6b7280; margin: 2px 0;
|
||||
}
|
||||
.m-info-row i { width: 14px; text-align: center; }
|
||||
|
||||
/* ===== Collapsible Detail ===== */
|
||||
.m-detail-toggle {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 12px; color: #3b82f6; font-weight: 500;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.m-detail-content {
|
||||
max-height: 0; overflow: hidden; transition: max-height 0.3s ease;
|
||||
}
|
||||
.m-detail-content.open { max-height: 2000px; }
|
||||
|
||||
/* ===== Completion Section ===== */
|
||||
.m-completion-info {
|
||||
background: #f5f3ff; border: 1px solid #ddd6fe;
|
||||
border-radius: 10px; padding: 12px; margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ===== Management Fields (read-only display) ===== */
|
||||
.m-field-display {
|
||||
padding: 8px 10px; background: #f9fafb; border-radius: 8px;
|
||||
border: 1px solid #e5e7eb; font-size: 13px; color: #374151;
|
||||
min-height: 36px;
|
||||
}
|
||||
.m-field-display.empty { color: #9ca3af; font-style: italic; }
|
||||
|
||||
/* ===== Radio Group ===== */
|
||||
.m-radio-group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.m-radio-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px; border-radius: 10px; border: 1px solid #e5e7eb;
|
||||
background: #fff; min-height: 44px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.m-radio-item.selected { border-color: #3b82f6; background: #eff6ff; }
|
||||
.m-radio-item input[type="radio"] { width: 18px; height: 18px; accent-color: #3b82f6; }
|
||||
|
||||
/* ===== Photo Upload ===== */
|
||||
.m-photo-upload {
|
||||
border: 2px dashed #d1d5db; border-radius: 12px;
|
||||
padding: 20px; text-align: center; color: #9ca3af;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.m-photo-upload:active { border-color: #3b82f6; }
|
||||
.m-photo-upload i { font-size: 28px; margin-bottom: 8px; }
|
||||
.m-photo-upload p { font-size: 13px; }
|
||||
.m-photo-preview {
|
||||
width: 100%; max-height: 200px; object-fit: contain;
|
||||
border-radius: 8px; margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ===== Fullscreen Photo Modal ===== */
|
||||
.m-photo-modal {
|
||||
position: fixed; inset: 0; z-index: 300;
|
||||
background: rgba(0,0,0,0.9);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
.m-photo-modal.open { opacity: 1; visibility: visible; }
|
||||
.m-photo-modal img {
|
||||
max-width: 100%; max-height: 100%; object-fit: contain;
|
||||
}
|
||||
.m-photo-modal-close {
|
||||
position: absolute; top: 12px; right: 12px;
|
||||
width: 36px; height: 36px; border-radius: 18px;
|
||||
background: rgba(0,0,0,0.5); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ===== Utility ===== */
|
||||
.hidden { display: none !important; }
|
||||
.text-ellipsis-2 {
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.text-ellipsis-3 {
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
297
system3-nonconformance/web/public/static/css/mobile-calendar.css
Normal file
297
system3-nonconformance/web/public/static/css/mobile-calendar.css
Normal file
@@ -0,0 +1,297 @@
|
||||
/* 모바일 캘린더 스타일 */
|
||||
.mobile-calendar {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* 빠른 선택 버튼들 */
|
||||
.quick-select-buttons {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.quick-select-buttons::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-btn:hover,
|
||||
.quick-btn:active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.quick-btn.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 캘린더 헤더 */
|
||||
.calendar-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-btn:hover,
|
||||
.nav-btn:active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.month-year {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 요일 헤더 */
|
||||
.weekdays {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
padding: 8px 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.weekday:first-child {
|
||||
color: #ef4444; /* 일요일 빨간색 */
|
||||
}
|
||||
|
||||
.weekday:last-child {
|
||||
color: #3b82f6; /* 토요일 파란색 */
|
||||
}
|
||||
|
||||
/* 캘린더 그리드 */
|
||||
.calendar-grid {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
background: white;
|
||||
border: 1px solid transparent;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.calendar-day:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 다른 달 날짜 */
|
||||
.calendar-day.other-month {
|
||||
color: #d1d5db;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.calendar-day.other-month:hover {
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 오늘 날짜 */
|
||||
.calendar-day.today {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-weight: 700;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.calendar-day.today:hover {
|
||||
background: #fde68a;
|
||||
}
|
||||
|
||||
/* 선택된 날짜 */
|
||||
.calendar-day.selected {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 범위 시작/끝 */
|
||||
.calendar-day.range-start,
|
||||
.calendar-day.range-end {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calendar-day.range-start:hover,
|
||||
.calendar-day.range-end:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
/* 범위 시작일에 표시 */
|
||||
.calendar-day.range-start::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 범위 끝일에 표시 */
|
||||
.calendar-day.range-end::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 선택된 범위 표시 */
|
||||
.selected-range {
|
||||
border: 1px solid #bfdbfe;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 사용법 안내 */
|
||||
.usage-hint {
|
||||
opacity: 0.7;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 터치 디바이스 최적화 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.calendar-day {
|
||||
min-height: 44px; /* 터치 타겟 최소 크기 */
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
min-height: 44px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 작은 화면 최적화 */
|
||||
@media (max-width: 375px) {
|
||||
.calendar-day {
|
||||
font-size: 13px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.month-year {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 다크 모드 지원 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.mobile-calendar {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
background: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: #374151;
|
||||
color: #f9fafb;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
background: #374151;
|
||||
color: #f9fafb;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
}
|
||||
404
system3-nonconformance/web/public/static/css/tkqc-common.css
Normal file
404
system3-nonconformance/web/public/static/css/tkqc-common.css
Normal file
@@ -0,0 +1,404 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* ===== 로딩 오버레이 ===== */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.loading-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* ===== 날짜 그룹 ===== */
|
||||
.date-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* ===== 이슈 카드 ===== */
|
||||
.issue-card {
|
||||
transition: all 0.2s ease;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.issue-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ===== 사진 프리뷰 ===== */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
464
system3-nonconformance/web/public/static/js/api.js
Normal file
464
system3-nonconformance/web/public/static/js/api.js
Normal file
@@ -0,0 +1,464 @@
|
||||
// SSO 쿠키 헬퍼
|
||||
function _cookieGet(name) {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
function _cookieRemove(name) {
|
||||
let cookie = name + '=; path=/; max-age=0';
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
}
|
||||
|
||||
// 중앙 로그인 URL (캐시 버스팅 포함)
|
||||
function _getLoginUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
const t = Date.now();
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return window.location.protocol + '//tkfb.technicalkorea.net/dashboard?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
|
||||
}
|
||||
return window.location.protocol + '//' + hostname + ':30000/dashboard?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
|
||||
}
|
||||
|
||||
// API 기본 설정 (통합 환경 지원)
|
||||
const API_BASE_URL = (() => {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
|
||||
// 프로덕션 (technicalkorea.net) - 같은 도메인 /api
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return protocol + '//' + hostname + '/api';
|
||||
}
|
||||
|
||||
// 통합 개발 환경 (포트 30280)
|
||||
if (port === '30280' || port === '30000') {
|
||||
return protocol + '//' + hostname + ':30200/api';
|
||||
}
|
||||
|
||||
// 기존 TKQC 로컬 환경 (포트 16080)
|
||||
if (port === '16080') {
|
||||
return protocol + '//' + hostname + ':16080/api';
|
||||
}
|
||||
|
||||
// 통합 Docker 환경에서 직접 접근 (포트 30280)
|
||||
if (port === '30280') {
|
||||
return protocol + '//' + hostname + ':30200/api';
|
||||
}
|
||||
|
||||
// 기타 환경
|
||||
return '/api';
|
||||
})();
|
||||
|
||||
// 토큰 관리 (SSO 쿠키 + localStorage 이중 지원)
|
||||
const TokenManager = {
|
||||
getToken: () => {
|
||||
// SSO 쿠키 우선, localStorage 폴백
|
||||
return _cookieGet('sso_token') || localStorage.getItem('sso_token');
|
||||
},
|
||||
setToken: (token) => localStorage.setItem('sso_token', token),
|
||||
removeToken: () => {
|
||||
_cookieRemove('sso_token');
|
||||
_cookieRemove('sso_user');
|
||||
_cookieRemove('sso_refresh_token');
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
},
|
||||
|
||||
getUser: () => {
|
||||
const ssoUser = _cookieGet('sso_user') || localStorage.getItem('sso_user');
|
||||
if (ssoUser) {
|
||||
try { return JSON.parse(ssoUser); } catch(e) {}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setUser: (user) => localStorage.setItem('sso_user', JSON.stringify(user)),
|
||||
removeUser: () => {
|
||||
localStorage.removeItem('sso_user');
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 노출 (permissions.js 등 다른 스크립트에서 접근)
|
||||
window.TokenManager = TokenManager;
|
||||
window.API_BASE_URL = API_BASE_URL;
|
||||
|
||||
// API 요청 헬퍼
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
|
||||
|
||||
if (response.status === 401) {
|
||||
// 인증 실패 — 토큰만 정리하고 에러 throw (리다이렉트는 auth-manager가 처리)
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
throw new Error('인증이 만료되었습니다.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('API Error Response:', error);
|
||||
console.error('Error details:', JSON.stringify(error, null, 2));
|
||||
|
||||
// 422 에러의 경우 validation 에러 메시지 추출
|
||||
if (response.status === 422 && error.detail && Array.isArray(error.detail)) {
|
||||
const validationErrors = error.detail.map(err =>
|
||||
`${err.loc ? err.loc.join('.') : 'field'}: ${err.msg}`
|
||||
).join(', ');
|
||||
throw new Error(`입력값 검증 오류: ${validationErrors}`);
|
||||
}
|
||||
|
||||
throw new Error(error.detail || 'API 요청 실패');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API 요청 에러:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth API
|
||||
const AuthAPI = {
|
||||
login: async (username, password) => {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: formData.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('로그인 에러:', error);
|
||||
throw new Error(error.detail || '로그인 실패');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
TokenManager.setToken(data.access_token);
|
||||
TokenManager.setUser(data.user);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('로그인 요청 에러:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = _getLoginUrl();
|
||||
},
|
||||
|
||||
getMe: () => apiRequest('/auth/me'),
|
||||
|
||||
getCurrentUser: () => apiRequest('/auth/me'),
|
||||
|
||||
getUsers: () => {
|
||||
return apiRequest('/auth/users');
|
||||
},
|
||||
|
||||
changePassword: (currentPassword, newPassword) => apiRequest('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
}),
|
||||
|
||||
// 부서 목록 가져오기
|
||||
getDepartments: () => [
|
||||
{ value: 'production', label: '생산' },
|
||||
{ value: 'quality', label: '품질' },
|
||||
{ value: 'purchasing', label: '구매' },
|
||||
{ value: 'design', label: '설계' },
|
||||
{ value: 'sales', label: '영업' }
|
||||
],
|
||||
|
||||
// 부서명 변환
|
||||
getDepartmentLabel: (departmentValue) => {
|
||||
const departments = AuthAPI.getDepartments();
|
||||
const dept = departments.find(d => d.value === departmentValue);
|
||||
return dept ? dept.label : departmentValue || '미지정';
|
||||
}
|
||||
};
|
||||
|
||||
// Issues API
|
||||
const IssuesAPI = {
|
||||
create: async (issueData) => {
|
||||
// photos 배열 처리 (최대 5장)
|
||||
const dataToSend = {
|
||||
category: issueData.category,
|
||||
description: issueData.description,
|
||||
project_id: issueData.project_id,
|
||||
photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null,
|
||||
photo2: issueData.photos && issueData.photos.length > 1 ? issueData.photos[1] : null,
|
||||
photo3: issueData.photos && issueData.photos.length > 2 ? issueData.photos[2] : null,
|
||||
photo4: issueData.photos && issueData.photos.length > 3 ? issueData.photos[3] : null,
|
||||
photo5: issueData.photos && issueData.photos.length > 4 ? issueData.photos[4] : null
|
||||
};
|
||||
|
||||
return apiRequest('/issues/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dataToSend)
|
||||
});
|
||||
},
|
||||
|
||||
getAll: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return apiRequest(`/issues/${queryString ? '?' + queryString : ''}`);
|
||||
},
|
||||
|
||||
get: (id) => apiRequest(`/issues/${id}`),
|
||||
|
||||
update: (id, issueData) => apiRequest(`/issues/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(issueData)
|
||||
}),
|
||||
|
||||
delete: (id) => apiRequest(`/issues/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
|
||||
getStats: () => apiRequest('/issues/stats/summary')
|
||||
};
|
||||
|
||||
// Reports API
|
||||
const ReportsAPI = {
|
||||
getSummary: (startDate, endDate) => apiRequest('/reports/summary', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
})
|
||||
}),
|
||||
|
||||
getIssues: (startDate, endDate) => {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}).toString();
|
||||
return apiRequest(`/reports/issues?${params}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 권한 체크 — authManager.checkAuth()로 통일 권장
|
||||
// 레거시 호환용으로 유지 (localStorage만 체크, API 호출 없음)
|
||||
function checkAuth() {
|
||||
const user = TokenManager.getUser();
|
||||
if (!user) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
function checkPageAccess(pageName) {
|
||||
const user = checkAuth();
|
||||
if (!user) return null;
|
||||
if (user.role === 'admin') return user;
|
||||
if (window.pagePermissionManager && !window.pagePermissionManager.canAccessPage(pageName)) {
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// AI API
|
||||
const AiAPI = {
|
||||
getSimilarIssues: async (issueId, limit = 5) => {
|
||||
try {
|
||||
const res = await fetch(`/ai-api/similar/${issueId}?n_results=${limit}`, {
|
||||
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
|
||||
});
|
||||
if (!res.ok) return { available: false, results: [] };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 유사 검색 실패:', e);
|
||||
return { available: false, results: [] };
|
||||
}
|
||||
},
|
||||
searchSimilar: async (query, limit = 5, filters = {}) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/similar/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ query, n_results: limit, ...filters })
|
||||
});
|
||||
if (!res.ok) return { available: false, results: [] };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 검색 실패:', e);
|
||||
return { available: false, results: [] };
|
||||
}
|
||||
},
|
||||
classifyIssue: async (description, detailNotes = '') => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/classify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ description, detail_notes: detailNotes })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 분류 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
generateDailyReport: async (date, projectId) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/report/daily', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ date, project_id: projectId })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 보고서 생성 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
syncSingleIssue: async (issueId) => {
|
||||
try {
|
||||
await fetch('/ai-api/embeddings/sync-single', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ issue_id: issueId })
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('AI 임베딩 동기화 실패 (무시):', e.message);
|
||||
}
|
||||
},
|
||||
syncEmbeddings: async () => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/embeddings/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
|
||||
});
|
||||
if (!res.ok) return { status: 'error' };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return { status: 'error' };
|
||||
}
|
||||
},
|
||||
checkHealth: async () => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/health');
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return { status: 'disconnected' };
|
||||
}
|
||||
},
|
||||
// RAG: 해결방안 제안
|
||||
suggestSolution: async (issueId) => {
|
||||
try {
|
||||
const res = await fetch(`/ai-api/rag/suggest-solution/${issueId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 해결방안 제안 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
// RAG: 자연어 질의
|
||||
askQuestion: async (question, projectId = null) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/rag/ask', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ question, project_id: projectId })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 질의 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
// RAG: 패턴 분석
|
||||
analyzePattern: async (description) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/rag/pattern', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ description })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 패턴 분석 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
// RAG: 강화 분류 (과거 사례 참고)
|
||||
classifyWithRAG: async (description, detailNotes = '') => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/rag/classify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ description, detail_notes: detailNotes })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI RAG 분류 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 API
|
||||
const ProjectsAPI = {
|
||||
getAll: (activeOnly = false) => {
|
||||
const params = `?active_only=${activeOnly}`;
|
||||
return apiRequest(`/projects/${params}`);
|
||||
},
|
||||
|
||||
get: (id) => apiRequest(`/projects/${id}`)
|
||||
};
|
||||
474
system3-nonconformance/web/public/static/js/app.js
Normal file
474
system3-nonconformance/web/public/static/js/app.js
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 메인 애플리케이션 JavaScript
|
||||
* 통합된 SPA 애플리케이션의 핵심 로직
|
||||
*/
|
||||
|
||||
class App {
|
||||
constructor() {
|
||||
this.currentUser = null;
|
||||
this.currentPage = 'dashboard';
|
||||
this.modules = new Map();
|
||||
this.sidebarCollapsed = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플리케이션 초기화
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
// 인증 확인
|
||||
await this.checkAuth();
|
||||
|
||||
// API 스크립트 로드
|
||||
await this.loadAPIScript();
|
||||
|
||||
// 권한 시스템 초기화
|
||||
window.pagePermissionManager.setUser(this.currentUser);
|
||||
|
||||
// UI 초기화
|
||||
this.initializeUI();
|
||||
|
||||
// 라우터 초기화
|
||||
this.initializeRouter();
|
||||
|
||||
// 알림 벨 로드
|
||||
this._loadNotificationBell();
|
||||
|
||||
// 대시보드 데이터 로드
|
||||
await this.loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('앱 초기화 실패:', error);
|
||||
this.redirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 확인
|
||||
*/
|
||||
async checkAuth() {
|
||||
// 쿠키 우선 검증: 쿠키 없고 localStorage에만 토큰이 있으면 정리
|
||||
const cookieToken = this._cookieGet('sso_token');
|
||||
const localToken = localStorage.getItem('sso_token');
|
||||
if (!cookieToken && localToken) {
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
|
||||
'currentUser','current_user','userInfo','userPageAccess'].forEach(k => localStorage.removeItem(k));
|
||||
throw new Error('쿠키 없음 - 로그아웃 상태');
|
||||
}
|
||||
|
||||
// SSO 쿠키 우선, localStorage 폴백
|
||||
const token = cookieToken || localToken;
|
||||
if (!token) {
|
||||
throw new Error('토큰 없음');
|
||||
}
|
||||
|
||||
const ssoUser = this._cookieGet('sso_user') || localStorage.getItem('sso_user');
|
||||
if (ssoUser) {
|
||||
try { this.currentUser = JSON.parse(ssoUser); return; } catch(e) {}
|
||||
}
|
||||
throw new Error('사용자 정보 없음');
|
||||
}
|
||||
|
||||
_cookieGet(name) {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 스크립트 동적 로드
|
||||
*/
|
||||
async loadAPIScript() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `/static/js/api.js?v=${Date.now()}`;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 초기화
|
||||
*/
|
||||
initializeUI() {
|
||||
// 사용자 정보 표시
|
||||
this.updateUserDisplay();
|
||||
|
||||
// 네비게이션 메뉴 생성
|
||||
this.createNavigationMenu();
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
this.registerEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 정보 표시 업데이트
|
||||
*/
|
||||
updateUserDisplay() {
|
||||
const userInitial = document.getElementById('userInitial');
|
||||
const userDisplayName = document.getElementById('userDisplayName');
|
||||
const userRole = document.getElementById('userRole');
|
||||
|
||||
const displayName = this.currentUser.full_name || this.currentUser.username;
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
|
||||
userInitial.textContent = initial;
|
||||
userDisplayName.textContent = displayName;
|
||||
userRole.textContent = this.getRoleDisplayName(this.currentUser.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할 표시명 가져오기
|
||||
*/
|
||||
getRoleDisplayName(role) {
|
||||
const roleNames = {
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleNames[role] || role;
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 메뉴 생성
|
||||
*/
|
||||
createNavigationMenu() {
|
||||
const menuConfig = window.pagePermissionManager.getMenuConfig();
|
||||
const navigationMenu = document.getElementById('navigationMenu');
|
||||
|
||||
navigationMenu.innerHTML = '';
|
||||
|
||||
menuConfig.forEach(item => {
|
||||
const menuItem = this.createMenuItem(item);
|
||||
navigationMenu.appendChild(menuItem);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 아이템 생성
|
||||
*/
|
||||
createMenuItem(item) {
|
||||
const li = document.createElement('li');
|
||||
|
||||
// 단순한 단일 메뉴 아이템만 지원
|
||||
li.innerHTML = `
|
||||
<div class="nav-item p-3 rounded-lg cursor-pointer" onclick="app.navigateTo('${item.path}')">
|
||||
<div class="flex items-center">
|
||||
<i class="${item.icon} mr-3 text-gray-500"></i>
|
||||
<span class="text-gray-700">${item.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 라우터 초기화
|
||||
*/
|
||||
initializeRouter() {
|
||||
// 해시 변경 감지
|
||||
window.addEventListener('hashchange', () => {
|
||||
this.handleRouteChange();
|
||||
});
|
||||
|
||||
// 초기 라우트 처리
|
||||
this.handleRouteChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 변경 처리
|
||||
*/
|
||||
async handleRouteChange() {
|
||||
const hash = window.location.hash.substring(1) || 'dashboard';
|
||||
const [module, action] = hash.split('/');
|
||||
|
||||
try {
|
||||
await this.loadModule(module, action);
|
||||
this.updateActiveNavigation(hash);
|
||||
this.updatePageTitle(module, action);
|
||||
} catch (error) {
|
||||
console.error('라우트 처리 실패:', error);
|
||||
this.showError('페이지를 로드할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 로드
|
||||
*/
|
||||
async loadModule(module, action = 'list') {
|
||||
if (module === 'dashboard') {
|
||||
this.showDashboard();
|
||||
return;
|
||||
}
|
||||
|
||||
// 모듈이 이미 로드되어 있는지 확인
|
||||
if (!this.modules.has(module)) {
|
||||
await this.loadModuleScript(module);
|
||||
}
|
||||
|
||||
// 모듈 실행
|
||||
const moduleInstance = this.modules.get(module);
|
||||
if (moduleInstance && typeof moduleInstance.render === 'function') {
|
||||
const content = await moduleInstance.render(action);
|
||||
this.showDynamicContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 스크립트 로드
|
||||
*/
|
||||
async loadModuleScript(module) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `/static/js/modules/${module}/${module}.js?v=${Date.now()}`;
|
||||
script.onload = () => {
|
||||
// 모듈이 전역 객체에 등록되었는지 확인
|
||||
const moduleClass = window[module.charAt(0).toUpperCase() + module.slice(1) + 'Module'];
|
||||
if (moduleClass) {
|
||||
this.modules.set(module, new moduleClass());
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 표시
|
||||
*/
|
||||
showDashboard() {
|
||||
document.getElementById('dashboard').classList.remove('hidden');
|
||||
document.getElementById('dynamicContent').classList.add('hidden');
|
||||
this.currentPage = 'dashboard';
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 콘텐츠 표시
|
||||
*/
|
||||
showDynamicContent(content) {
|
||||
document.getElementById('dashboard').classList.add('hidden');
|
||||
const dynamicContent = document.getElementById('dynamicContent');
|
||||
dynamicContent.innerHTML = content;
|
||||
dynamicContent.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 활성화 상태 업데이트
|
||||
*/
|
||||
updateActiveNavigation(hash) {
|
||||
// 모든 네비게이션 아이템에서 active 클래스 제거
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 현재 페이지에 해당하는 네비게이션 아이템에 active 클래스 추가
|
||||
// 구현 필요
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 제목 업데이트
|
||||
*/
|
||||
updatePageTitle(module, action) {
|
||||
const titles = {
|
||||
'dashboard': '대시보드',
|
||||
'issues': '부적합 사항',
|
||||
'projects': '프로젝트',
|
||||
'reports': '보고서'
|
||||
};
|
||||
|
||||
const title = titles[module] || module;
|
||||
document.getElementById('pageTitle').textContent = title;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 데이터 로드
|
||||
*/
|
||||
async loadDashboardData() {
|
||||
try {
|
||||
// 통계 데이터 로드 (임시 데이터)
|
||||
document.getElementById('totalIssues').textContent = '0';
|
||||
document.getElementById('activeProjects').textContent = '0';
|
||||
document.getElementById('monthlyHours').textContent = '0';
|
||||
document.getElementById('completionRate').textContent = '0%';
|
||||
|
||||
// 실제 API 호출로 대체 예정
|
||||
// const stats = await API.getDashboardStats();
|
||||
// this.updateDashboardStats(stats);
|
||||
} catch (error) {
|
||||
console.error('대시보드 데이터 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 등록
|
||||
*/
|
||||
registerEventListeners() {
|
||||
// 비밀번호 변경은 CommonHeader에서 처리
|
||||
|
||||
// 모바일 반응형
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
this.hideMobileOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 이동
|
||||
*/
|
||||
navigateTo(path) {
|
||||
window.location.hash = path.startsWith('#') ? path.substring(1) : path;
|
||||
|
||||
// 모바일에서 사이드바 닫기
|
||||
if (window.innerWidth < 768) {
|
||||
this.toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드바 토글
|
||||
*/
|
||||
toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const mainContent = document.getElementById('mainContent');
|
||||
const mobileOverlay = document.getElementById('mobileOverlay');
|
||||
|
||||
if (window.innerWidth < 768) {
|
||||
// 모바일
|
||||
if (sidebar.classList.contains('collapsed')) {
|
||||
sidebar.classList.remove('collapsed');
|
||||
mobileOverlay.classList.add('active');
|
||||
} else {
|
||||
sidebar.classList.add('collapsed');
|
||||
mobileOverlay.classList.remove('active');
|
||||
}
|
||||
} else {
|
||||
// 데스크톱
|
||||
if (this.sidebarCollapsed) {
|
||||
sidebar.classList.remove('collapsed');
|
||||
mainContent.classList.remove('expanded');
|
||||
this.sidebarCollapsed = false;
|
||||
} else {
|
||||
sidebar.classList.add('collapsed');
|
||||
mainContent.classList.add('expanded');
|
||||
this.sidebarCollapsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모바일 오버레이 숨기기
|
||||
*/
|
||||
hideMobileOverlay() {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
document.getElementById('mobileOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
// 비밀번호 변경 기능은 CommonHeader.js에서 처리됩니다.
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
logout() {
|
||||
if (window.authManager) {
|
||||
window.authManager.clearAuth();
|
||||
} else {
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
}
|
||||
this.redirectToLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* 중앙 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
redirectToLogin() {
|
||||
const hostname = window.location.hostname;
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
window.location.href = window.location.protocol + '//tkfb.technicalkorea.net/dashboard?redirect=' + encodeURIComponent(window.location.href);
|
||||
} else {
|
||||
window.location.href = window.location.protocol + '//' + hostname + ':30000/dashboard?redirect=' + encodeURIComponent(window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 벨 로드
|
||||
*/
|
||||
_loadNotificationBell() {
|
||||
var h = window.location.hostname;
|
||||
var s = document.createElement('script');
|
||||
s.src = (h.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=4';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 표시
|
||||
*/
|
||||
showLoading() {
|
||||
document.getElementById('loadingOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 숨기기
|
||||
*/
|
||||
hideLoading() {
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 메시지 표시
|
||||
*/
|
||||
showSuccess(message) {
|
||||
this.showToast(message, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 메시지 표시
|
||||
*/
|
||||
showError(message) {
|
||||
this.showToast(message, 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
|
||||
type === 'success' ? 'bg-green-500' :
|
||||
type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
||||
}`;
|
||||
toast.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'} mr-2"></i>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수들 (HTML에서 호출)
|
||||
function toggleSidebar() {
|
||||
window.app.toggleSidebar();
|
||||
}
|
||||
|
||||
// 비밀번호 변경 기능은 CommonHeader.showPasswordModal()을 사용합니다.
|
||||
|
||||
function logout() {
|
||||
window.app.logout();
|
||||
}
|
||||
|
||||
// 앱 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.app = new App();
|
||||
});
|
||||
@@ -0,0 +1,725 @@
|
||||
/**
|
||||
* 공통 헤더 컴포넌트
|
||||
* 권한 기반으로 메뉴를 동적으로 생성하고 부드러운 페이지 전환을 제공
|
||||
*/
|
||||
|
||||
class CommonHeader {
|
||||
constructor() {
|
||||
this.currentUser = null;
|
||||
this.currentPage = '';
|
||||
this.menuItems = this.initMenuItems();
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 아이템 정의
|
||||
*/
|
||||
initMenuItems() {
|
||||
return [
|
||||
{
|
||||
id: 'issues_dashboard',
|
||||
title: '현황판',
|
||||
icon: 'fas fa-chart-line',
|
||||
url: '/issues-dashboard.html',
|
||||
pageName: 'issues_dashboard',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100'
|
||||
},
|
||||
{
|
||||
id: 'issues_inbox',
|
||||
title: '수신함',
|
||||
icon: 'fas fa-inbox',
|
||||
url: '/issues-inbox.html',
|
||||
pageName: 'issues_inbox',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100'
|
||||
},
|
||||
{
|
||||
id: 'issues_management',
|
||||
title: '관리함',
|
||||
icon: 'fas fa-cog',
|
||||
url: '/issues-management.html',
|
||||
pageName: 'issues_management',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100'
|
||||
},
|
||||
{
|
||||
id: 'issues_archive',
|
||||
title: '폐기함',
|
||||
icon: 'fas fa-archive',
|
||||
url: '/issues-archive.html',
|
||||
pageName: 'issues_archive',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100'
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
title: '보고서',
|
||||
icon: 'fas fa-chart-bar',
|
||||
url: '/reports.html',
|
||||
pageName: 'reports',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100',
|
||||
subMenus: [
|
||||
{
|
||||
id: 'reports_daily',
|
||||
title: '일일보고서',
|
||||
icon: 'fas fa-file-excel',
|
||||
url: '/reports-daily.html',
|
||||
pageName: 'reports_daily',
|
||||
color: 'text-slate-600'
|
||||
},
|
||||
{
|
||||
id: 'reports_weekly',
|
||||
title: '주간보고서',
|
||||
icon: 'fas fa-calendar-week',
|
||||
url: '/reports-weekly.html',
|
||||
pageName: 'reports_weekly',
|
||||
color: 'text-slate-600'
|
||||
},
|
||||
{
|
||||
id: 'reports_monthly',
|
||||
title: '월간보고서',
|
||||
icon: 'fas fa-calendar-alt',
|
||||
url: '/reports-monthly.html',
|
||||
pageName: 'reports_monthly',
|
||||
color: 'text-slate-600'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ai_assistant',
|
||||
title: 'AI 어시스턴트',
|
||||
icon: 'fas fa-robot',
|
||||
url: '/ai-assistant.html',
|
||||
pageName: 'ai_assistant',
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'text-purple-600 hover:bg-purple-50'
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
title: '신고',
|
||||
icon: 'fas fa-exclamation-triangle',
|
||||
url: this.getReportUrl(),
|
||||
pageName: 'report',
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'text-orange-600 hover:bg-orange-50',
|
||||
external: true
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* tkreport URL 생성
|
||||
*/
|
||||
getReportUrl() {
|
||||
var host = window.location.hostname;
|
||||
var protocol = window.location.protocol;
|
||||
if (host.includes('technicalkorea.net')) {
|
||||
return protocol + '//tkreport.technicalkorea.net/pages/safety/issue-report.html';
|
||||
}
|
||||
return protocol + '//' + host + ':30100/pages/safety/issue-report.html';
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 초기화
|
||||
* @param {Object} user - 현재 사용자 정보
|
||||
* @param {string} currentPage - 현재 페이지 ID
|
||||
*/
|
||||
async init(user, currentPage = '') {
|
||||
this.currentUser = user;
|
||||
this.currentPage = currentPage;
|
||||
|
||||
// 권한 시스템이 로드될 때까지 대기
|
||||
await this.waitForPermissionSystem();
|
||||
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
|
||||
// 키보드 단축키 초기화
|
||||
this.initializeKeyboardShortcuts();
|
||||
|
||||
// 페이지 프리로더 초기화
|
||||
this.initializePreloader();
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 시스템 로드 대기
|
||||
*/
|
||||
async waitForPermissionSystem() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5초 대기
|
||||
|
||||
while (!window.pagePermissionManager && attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (window.pagePermissionManager && this.currentUser) {
|
||||
window.pagePermissionManager.setUser(this.currentUser);
|
||||
// 권한 로드 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 렌더링
|
||||
*/
|
||||
render() {
|
||||
const headerHTML = this.generateHeaderHTML();
|
||||
|
||||
// 기존 헤더가 있으면 교체, 없으면 body 상단에 추가
|
||||
let headerContainer = document.getElementById('common-header');
|
||||
if (headerContainer) {
|
||||
headerContainer.innerHTML = headerHTML;
|
||||
} else {
|
||||
headerContainer = document.createElement('div');
|
||||
headerContainer.id = 'common-header';
|
||||
headerContainer.innerHTML = headerHTML;
|
||||
document.body.insertBefore(headerContainer, document.body.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지 업데이트
|
||||
* @param {string} pageName - 새로운 페이지 이름
|
||||
*/
|
||||
updateCurrentPage(pageName) {
|
||||
this.currentPage = pageName;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 HTML 생성
|
||||
*/
|
||||
generateHeaderHTML() {
|
||||
const accessibleMenus = this.getAccessibleMenus();
|
||||
const userDisplayName = this.currentUser?.full_name || this.currentUser?.username || '사용자';
|
||||
const userRole = this.getUserRoleDisplay();
|
||||
|
||||
return `
|
||||
<header class="bg-white shadow-sm border-b sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 로고 및 제목 -->
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<i class="fas fa-shield-halved text-2xl text-slate-700 mr-3"></i>
|
||||
<h1 class="text-xl font-bold text-gray-900">부적합 관리</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 네비게이션 메뉴 -->
|
||||
<nav class="hidden md:flex space-x-2">
|
||||
${accessibleMenus.map(menu => this.generateMenuItemHTML(menu)).join('')}
|
||||
</nav>
|
||||
|
||||
<!-- 사용자 정보 및 메뉴 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 사용자 정보 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-medium text-gray-900">${userDisplayName}</div>
|
||||
<div class="text-xs text-gray-500">${userRole}</div>
|
||||
</div>
|
||||
<div class="w-8 h-8 bg-slate-600 rounded-full flex items-center justify-center">
|
||||
<span class="text-white text-sm font-semibold">
|
||||
${userDisplayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 드롭다운 메뉴 -->
|
||||
<div class="relative">
|
||||
<button id="user-menu-button" class="p-2 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-md">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5">
|
||||
<a href="#" onclick="CommonHeader.showPasswordModal()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-key mr-2"></i>비밀번호 변경
|
||||
</a>
|
||||
<a href="#" onclick="CommonHeader.logout()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i>로그아웃
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모바일 메뉴 버튼 -->
|
||||
<button id="mobile-menu-button" class="md:hidden p-2 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-md">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모바일 메뉴 -->
|
||||
<div id="mobile-menu" class="md:hidden hidden border-t border-gray-200 py-3">
|
||||
<div class="space-y-1">
|
||||
${accessibleMenus.map(menu => this.generateMobileMenuItemHTML(menu)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 가능한 메뉴 필터링
|
||||
*/
|
||||
getAccessibleMenus() {
|
||||
return this.menuItems.filter(menu => {
|
||||
// admin은 모든 메뉴 접근 가능
|
||||
if (this.currentUser?.role === 'admin') {
|
||||
// 하위 메뉴가 있는 경우 하위 메뉴도 필터링
|
||||
if (menu.subMenus) {
|
||||
menu.accessibleSubMenus = menu.subMenus;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 권한 시스템이 로드되지 않았으면 기본 메뉴만
|
||||
if (!window.canAccessPage) {
|
||||
return ['issues_dashboard', 'issues_inbox'].includes(menu.id);
|
||||
}
|
||||
|
||||
// 메인 메뉴 권한 체크
|
||||
const hasMainAccess = window.canAccessPage(menu.pageName);
|
||||
|
||||
// 하위 메뉴가 있는 경우 접근 가능한 하위 메뉴 필터링
|
||||
if (menu.subMenus) {
|
||||
menu.accessibleSubMenus = menu.subMenus.filter(subMenu =>
|
||||
window.canAccessPage(subMenu.pageName)
|
||||
);
|
||||
|
||||
// 메인 메뉴 접근 권한이 없어도 하위 메뉴 중 하나라도 접근 가능하면 표시
|
||||
return hasMainAccess || menu.accessibleSubMenus.length > 0;
|
||||
}
|
||||
|
||||
return hasMainAccess;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 데스크톱 메뉴 아이템 HTML 생성
|
||||
*/
|
||||
generateMenuItemHTML(menu) {
|
||||
const isActive = this.currentPage === menu.id;
|
||||
const activeClass = isActive ? 'bg-slate-700 text-white' : `${menu.bgColor} ${menu.color}`;
|
||||
|
||||
// 하위 메뉴가 있는 경우 드롭다운 메뉴 생성
|
||||
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
|
||||
return `
|
||||
<div class="relative group">
|
||||
<button class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
|
||||
data-page="${menu.id}">
|
||||
<i class="${menu.icon} mr-2"></i>
|
||||
${menu.title}
|
||||
<i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||
</button>
|
||||
|
||||
<!-- 드롭다운 메뉴 -->
|
||||
<div class="absolute left-0 mt-1 w-48 bg-white rounded-md shadow-lg border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||
<div class="py-1">
|
||||
${menu.accessibleSubMenus.map(subMenu => `
|
||||
<a href="${subMenu.url}"
|
||||
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100 ${this.currentPage === subMenu.id ? 'bg-slate-100 text-slate-800 font-medium' : ''}"
|
||||
data-page="${subMenu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
|
||||
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
|
||||
${subMenu.title}
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 외부 링크 (tkuser 등)
|
||||
if (menu.external) {
|
||||
return `
|
||||
<a href="${menu.url}"
|
||||
class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
|
||||
data-page="${menu.id}">
|
||||
<i class="${menu.icon} mr-2"></i>
|
||||
${menu.title}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
// 일반 메뉴 아이템
|
||||
return `
|
||||
<a href="${menu.url}"
|
||||
class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
|
||||
data-page="${menu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
|
||||
<i class="${menu.icon} mr-2"></i>
|
||||
${menu.title}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모바일 메뉴 아이템 HTML 생성
|
||||
*/
|
||||
generateMobileMenuItemHTML(menu) {
|
||||
const isActive = this.currentPage === menu.id;
|
||||
const activeClass = isActive ? 'bg-slate-100 text-slate-800 border-slate-600' : 'text-gray-700 hover:bg-gray-50';
|
||||
|
||||
// 하위 메뉴가 있는 경우
|
||||
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
|
||||
return `
|
||||
<div class="mobile-submenu-container">
|
||||
<button class="w-full flex items-center justify-between px-3 py-2 rounded-md text-base font-medium border-l-4 border-transparent ${activeClass}"
|
||||
onclick="this.nextElementSibling.classList.toggle('hidden')"
|
||||
data-page="${menu.id}">
|
||||
<div class="flex items-center">
|
||||
<i class="${menu.icon} mr-3"></i>
|
||||
${menu.title}
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-xs"></i>
|
||||
</button>
|
||||
|
||||
<!-- 하위 메뉴 -->
|
||||
<div class="hidden ml-6 mt-1 space-y-1">
|
||||
${menu.accessibleSubMenus.map(subMenu => `
|
||||
<a href="${subMenu.url}"
|
||||
class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-slate-100 ${this.currentPage === subMenu.id ? 'bg-slate-100 text-slate-800' : ''}"
|
||||
data-page="${subMenu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
|
||||
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
|
||||
${subMenu.title}
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 외부 링크
|
||||
if (menu.external) {
|
||||
return `
|
||||
<a href="${menu.url}"
|
||||
class="nav-item block px-3 py-2 rounded-md text-base font-medium border-l-4 border-transparent ${activeClass}"
|
||||
data-page="${menu.id}">
|
||||
<i class="${menu.icon} mr-3"></i>
|
||||
${menu.title}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
// 일반 메뉴 아이템
|
||||
return `
|
||||
<a href="${menu.url}"
|
||||
class="nav-item block px-3 py-2 rounded-md text-base font-medium border-l-4 border-transparent ${activeClass}"
|
||||
data-page="${menu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
|
||||
<i class="${menu.icon} mr-3"></i>
|
||||
${menu.title}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 역할 표시명 가져오기
|
||||
*/
|
||||
getUserRoleDisplay() {
|
||||
const roleNames = {
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleNames[this.currentUser?.role] || '사용자';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 바인딩
|
||||
*/
|
||||
bindEvents() {
|
||||
// 사용자 메뉴 토글
|
||||
const userMenuButton = document.getElementById('user-menu-button');
|
||||
const userMenu = document.getElementById('user-menu');
|
||||
|
||||
if (userMenuButton && userMenu) {
|
||||
userMenuButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
userMenu.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// 외부 클릭 시 메뉴 닫기
|
||||
document.addEventListener('click', () => {
|
||||
userMenu.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// 모바일 메뉴 토글
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', () => {
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 네비게이션 (부드러운 전환)
|
||||
*/
|
||||
static navigateToPage(event, url, pageId) {
|
||||
event.preventDefault();
|
||||
|
||||
// 현재 페이지와 같으면 무시
|
||||
if (window.commonHeader?.currentPage === pageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
CommonHeader.showPageTransition();
|
||||
|
||||
// 페이지 이동
|
||||
setTimeout(() => {
|
||||
window.location.href = url;
|
||||
}, 150); // 부드러운 전환을 위한 딜레이
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 전환 로딩 표시
|
||||
*/
|
||||
static showPageTransition() {
|
||||
// 기존 로딩이 있으면 제거
|
||||
const existingLoader = document.getElementById('page-transition-loader');
|
||||
if (existingLoader) {
|
||||
existingLoader.remove();
|
||||
}
|
||||
|
||||
const loader = document.createElement('div');
|
||||
loader.id = 'page-transition-loader';
|
||||
loader.className = 'fixed inset-0 bg-white bg-opacity-75 flex items-center justify-center z-50';
|
||||
loader.innerHTML = `
|
||||
<div class="text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-600">페이지를 로드하는 중...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 모달 표시
|
||||
*/
|
||||
static showPasswordModal() {
|
||||
// 기존 모달이 있으면 제거
|
||||
const existingModal = document.getElementById('passwordChangeModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// 비밀번호 변경 모달 생성
|
||||
const modalHTML = `
|
||||
<div id="passwordChangeModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]">
|
||||
<div class="bg-white rounded-xl p-6 w-96 max-w-md mx-4 shadow-2xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">
|
||||
<i class="fas fa-key mr-2 text-blue-500"></i>비밀번호 변경
|
||||
</h3>
|
||||
<button onclick="CommonHeader.hidePasswordModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="passwordChangeForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
|
||||
<input type="password" id="currentPasswordInput"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required placeholder="현재 비밀번호를 입력하세요">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
|
||||
<input type="password" id="newPasswordInput"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required minlength="6" placeholder="새 비밀번호 (최소 6자)">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
|
||||
<input type="password" id="confirmPasswordInput"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required placeholder="새 비밀번호를 다시 입력하세요">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="button" onclick="CommonHeader.hidePasswordModal()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-save mr-1"></i>변경
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
|
||||
// 폼 제출 이벤트 리스너 추가
|
||||
document.getElementById('passwordChangeForm').addEventListener('submit', CommonHeader.handlePasswordChange);
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
CommonHeader.hidePasswordModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 모달 숨기기
|
||||
*/
|
||||
static hidePasswordModal() {
|
||||
const modal = document.getElementById('passwordChangeModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 처리
|
||||
*/
|
||||
static async handlePasswordChange(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('currentPasswordInput').value;
|
||||
const newPassword = document.getElementById('newPasswordInput').value;
|
||||
const confirmPassword = document.getElementById('confirmPasswordInput').value;
|
||||
|
||||
// 새 비밀번호 확인
|
||||
if (newPassword !== confirmPassword) {
|
||||
CommonHeader.showToast('새 비밀번호가 일치하지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
CommonHeader.showToast('새 비밀번호는 최소 6자 이상이어야 합니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// AuthAPI가 있는지 확인
|
||||
if (typeof AuthAPI === 'undefined') {
|
||||
throw new Error('AuthAPI가 로드되지 않았습니다.');
|
||||
}
|
||||
|
||||
// API를 통한 비밀번호 변경
|
||||
await AuthAPI.changePassword(currentPassword, newPassword);
|
||||
|
||||
CommonHeader.showToast('비밀번호가 성공적으로 변경되었습니다.', 'success');
|
||||
CommonHeader.hidePasswordModal();
|
||||
|
||||
} catch (error) {
|
||||
console.error('비밀번호 변경 실패:', error);
|
||||
CommonHeader.showToast('현재 비밀번호가 올바르지 않거나 변경에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
static showToast(message, type = 'success') {
|
||||
// 기존 토스트 제거
|
||||
const existingToast = document.querySelector('.toast-message');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-message fixed bottom-4 right-4 px-4 py-3 rounded-lg text-white z-[10000] shadow-lg transform transition-all duration-300 ${
|
||||
type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`;
|
||||
|
||||
const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
|
||||
const _esc = s => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
|
||||
toast.innerHTML = `<i class="fas ${icon} mr-2"></i>${_esc(message)}`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 애니메이션 효과
|
||||
setTimeout(() => toast.classList.add('translate-x-0'), 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-x-full');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
static logout() {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
if (window.authManager) {
|
||||
window.authManager.logout();
|
||||
} else {
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
localStorage.removeItem('sso_user');
|
||||
var hostname = window.location.hostname;
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
window.location.href = window.location.protocol + '//tkfb.technicalkorea.net/dashboard';
|
||||
} else {
|
||||
window.location.href = window.location.protocol + '//' + hostname + ':30000/dashboard';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지 업데이트
|
||||
*/
|
||||
updateCurrentPage(pageId) {
|
||||
this.currentPage = pageId;
|
||||
|
||||
// 활성 메뉴 업데이트
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
const itemPageId = item.getAttribute('data-page');
|
||||
if (itemPageId === pageId) {
|
||||
item.classList.add('bg-slate-700', 'text-white');
|
||||
item.classList.remove('text-slate-600', 'hover:bg-slate-100');
|
||||
} else {
|
||||
item.classList.remove('bg-slate-700', 'text-white');
|
||||
item.classList.add('text-slate-600');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 키보드 단축키 초기화
|
||||
*/
|
||||
initializeKeyboardShortcuts() {
|
||||
if (window.keyboardShortcuts) {
|
||||
window.keyboardShortcuts.setUser(this.currentUser);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 프리로더 초기화
|
||||
*/
|
||||
initializePreloader() {
|
||||
if (window.pagePreloader) {
|
||||
// 사용자 설정 후 프리로더 초기화
|
||||
setTimeout(() => {
|
||||
window.pagePreloader.init();
|
||||
}, 1000); // 권한 시스템 로드 후 실행
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스
|
||||
window.commonHeader = new CommonHeader();
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.CommonHeader = CommonHeader;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* mobile-bottom-nav.js — tkqc 모바일 하단 네비게이션
|
||||
* 768px 이하에서 고정 하단바 표시
|
||||
*/
|
||||
|
||||
(function() {
|
||||
// 이미 삽입되었으면 스킵
|
||||
if (document.getElementById('tkqcMobileNav')) return;
|
||||
|
||||
const nav = document.createElement('nav');
|
||||
nav.id = 'tkqcMobileNav';
|
||||
nav.className = 'tkqc-mobile-nav';
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
const items = [
|
||||
{ href: '/issues-dashboard.html', icon: 'fas fa-chart-line', label: '현황판', page: 'dashboard' },
|
||||
{ href: '/issues-inbox.html', icon: 'fas fa-inbox', label: '수신함', page: 'inbox' },
|
||||
{ href: '/issues-management.html', icon: 'fas fa-tasks', label: '관리함', page: 'management' },
|
||||
{ href: '/issues-archive.html', icon: 'fas fa-archive', label: '폐기함', page: 'archive' }
|
||||
];
|
||||
|
||||
nav.innerHTML = items.map(item => {
|
||||
const isActive = currentPath.includes(item.page) || currentPath === item.href;
|
||||
return `
|
||||
<a href="${item.href}" class="tkqc-mobile-nav-item ${isActive ? 'active' : ''}">
|
||||
<i class="${item.icon}"></i>
|
||||
<span>${item.label}</span>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.body.appendChild(nav);
|
||||
})();
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 모바일 친화적 캘린더 컴포넌트
|
||||
* 터치 및 스와이프 지원, 날짜 범위 선택 기능
|
||||
*/
|
||||
|
||||
class MobileCalendar {
|
||||
constructor(containerId, options = {}) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.options = {
|
||||
locale: 'ko-KR',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
maxRange: 90, // 최대 90일 범위
|
||||
onDateSelect: null,
|
||||
onRangeSelect: null,
|
||||
...options
|
||||
};
|
||||
|
||||
this.currentDate = new Date();
|
||||
this.selectedStartDate = null;
|
||||
this.selectedEndDate = null;
|
||||
this.isSelecting = false;
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
render() {
|
||||
const calendarHTML = `
|
||||
<div class="mobile-calendar">
|
||||
<!-- 빠른 선택 버튼들 -->
|
||||
<div class="quick-select-buttons mb-4">
|
||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||
<button class="quick-btn" data-range="today">오늘</button>
|
||||
<button class="quick-btn" data-range="week">이번 주</button>
|
||||
<button class="quick-btn" data-range="month">이번 달</button>
|
||||
<button class="quick-btn" data-range="last7">최근 7일</button>
|
||||
<button class="quick-btn" data-range="last30">최근 30일</button>
|
||||
<button class="quick-btn" data-range="all">전체</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 헤더 -->
|
||||
<div class="calendar-header flex items-center justify-between mb-4">
|
||||
<button class="nav-btn" id="prevMonth">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<h3 class="month-year text-lg font-semibold" id="monthYear"></h3>
|
||||
<button class="nav-btn" id="nextMonth">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 요일 헤더 -->
|
||||
<div class="weekdays grid grid-cols-7 gap-1 mb-2">
|
||||
<div class="weekday">일</div>
|
||||
<div class="weekday">월</div>
|
||||
<div class="weekday">화</div>
|
||||
<div class="weekday">수</div>
|
||||
<div class="weekday">목</div>
|
||||
<div class="weekday">금</div>
|
||||
<div class="weekday">토</div>
|
||||
</div>
|
||||
|
||||
<!-- 날짜 그리드 -->
|
||||
<div class="calendar-grid grid grid-cols-7 gap-1" id="calendarGrid">
|
||||
<!-- 날짜들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 선택된 범위 표시 -->
|
||||
<div class="selected-range mt-4 p-3 bg-blue-50 rounded-lg" id="selectedRange" style="display: none;">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-blue-700" id="rangeText"></span>
|
||||
<button class="clear-btn text-blue-600 hover:text-blue-800" id="clearRange">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용법 안내 -->
|
||||
<div class="usage-hint mt-3 text-xs text-gray-500 text-center">
|
||||
📅 날짜를 터치하여 시작일을 선택하고, 다시 터치하여 종료일을 선택하세요
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = calendarHTML;
|
||||
this.updateCalendar();
|
||||
}
|
||||
|
||||
updateCalendar() {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
|
||||
// 월/년 표시 업데이트
|
||||
document.getElementById('monthYear').textContent =
|
||||
`${year}년 ${month + 1}월`;
|
||||
|
||||
// 캘린더 그리드 생성
|
||||
this.generateCalendarGrid(year, month);
|
||||
}
|
||||
|
||||
generateCalendarGrid(year, month) {
|
||||
const grid = document.getElementById('calendarGrid');
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
let html = '';
|
||||
const today = new Date();
|
||||
|
||||
// 6주 표시 (42일)
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(startDate.getDate() + i);
|
||||
|
||||
const isCurrentMonth = date.getMonth() === month;
|
||||
const isToday = this.isSameDate(date, today);
|
||||
const isSelected = this.isDateInRange(date);
|
||||
const isStart = this.selectedStartDate && this.isSameDate(date, this.selectedStartDate);
|
||||
const isEnd = this.selectedEndDate && this.isSameDate(date, this.selectedEndDate);
|
||||
|
||||
let classes = ['calendar-day'];
|
||||
if (!isCurrentMonth) classes.push('other-month');
|
||||
if (isToday) classes.push('today');
|
||||
if (isSelected) classes.push('selected');
|
||||
if (isStart) classes.push('range-start');
|
||||
if (isEnd) classes.push('range-end');
|
||||
|
||||
html += `
|
||||
<div class="${classes.join(' ')}"
|
||||
data-date="${date.toISOString().split('T')[0]}"
|
||||
data-timestamp="${date.getTime()}">
|
||||
${date.getDate()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 빠른 선택 버튼들
|
||||
this.container.querySelectorAll('.quick-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const range = e.target.dataset.range;
|
||||
this.selectQuickRange(range);
|
||||
});
|
||||
});
|
||||
|
||||
// 월 네비게이션
|
||||
document.getElementById('prevMonth').addEventListener('click', () => {
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||
this.updateCalendar();
|
||||
});
|
||||
|
||||
document.getElementById('nextMonth').addEventListener('click', () => {
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||
this.updateCalendar();
|
||||
});
|
||||
|
||||
// 날짜 선택
|
||||
this.container.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('calendar-day')) {
|
||||
this.handleDateClick(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
// 터치 이벤트 (스와이프 지원)
|
||||
this.container.addEventListener('touchstart', (e) => {
|
||||
this.touchStartX = e.touches[0].clientX;
|
||||
this.touchStartY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
this.container.addEventListener('touchend', (e) => {
|
||||
if (!this.touchStartX || !this.touchStartY) return;
|
||||
|
||||
const touchEndX = e.changedTouches[0].clientX;
|
||||
const touchEndY = e.changedTouches[0].clientY;
|
||||
|
||||
const diffX = this.touchStartX - touchEndX;
|
||||
const diffY = this.touchStartY - touchEndY;
|
||||
|
||||
// 수평 스와이프가 수직 스와이프보다 클 때만 처리
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
||||
if (diffX > 0) {
|
||||
// 왼쪽으로 스와이프 - 다음 달
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||
} else {
|
||||
// 오른쪽으로 스와이프 - 이전 달
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||
}
|
||||
this.updateCalendar();
|
||||
}
|
||||
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
});
|
||||
|
||||
// 범위 지우기
|
||||
document.getElementById('clearRange').addEventListener('click', () => {
|
||||
this.clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
handleDateClick(dayElement) {
|
||||
const dateStr = dayElement.dataset.date;
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
|
||||
if (!this.selectedStartDate || (this.selectedStartDate && this.selectedEndDate)) {
|
||||
// 새로운 선택 시작
|
||||
this.selectedStartDate = date;
|
||||
this.selectedEndDate = null;
|
||||
this.isSelecting = true;
|
||||
} else if (this.selectedStartDate && !this.selectedEndDate) {
|
||||
// 종료일 선택
|
||||
if (date < this.selectedStartDate) {
|
||||
// 시작일보다 이전 날짜를 선택하면 시작일로 설정
|
||||
this.selectedEndDate = this.selectedStartDate;
|
||||
this.selectedStartDate = date;
|
||||
} else {
|
||||
this.selectedEndDate = date;
|
||||
}
|
||||
this.isSelecting = false;
|
||||
|
||||
// 범위가 너무 크면 제한
|
||||
const daysDiff = Math.abs(this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24);
|
||||
if (daysDiff > this.options.maxRange) {
|
||||
alert(`최대 ${this.options.maxRange}일까지만 선택할 수 있습니다.`);
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCalendar();
|
||||
this.updateSelectedRange();
|
||||
|
||||
// 콜백 호출
|
||||
if (this.selectedStartDate && this.selectedEndDate && this.options.onRangeSelect) {
|
||||
this.options.onRangeSelect(this.selectedStartDate, this.selectedEndDate);
|
||||
}
|
||||
}
|
||||
|
||||
selectQuickRange(range) {
|
||||
const today = new Date();
|
||||
let startDate, endDate;
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
startDate = endDate = new Date(today);
|
||||
break;
|
||||
case 'week':
|
||||
startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - today.getDay());
|
||||
endDate = new Date(startDate);
|
||||
endDate.setDate(startDate.getDate() + 6);
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
break;
|
||||
case 'last7':
|
||||
endDate = new Date(today);
|
||||
startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - 6);
|
||||
break;
|
||||
case 'last30':
|
||||
endDate = new Date(today);
|
||||
startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - 29);
|
||||
break;
|
||||
case 'all':
|
||||
this.clearSelection();
|
||||
if (this.options.onRangeSelect) {
|
||||
this.options.onRangeSelect(null, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedStartDate = startDate;
|
||||
this.selectedEndDate = endDate;
|
||||
this.updateCalendar();
|
||||
this.updateSelectedRange();
|
||||
|
||||
if (this.options.onRangeSelect) {
|
||||
this.options.onRangeSelect(startDate, endDate);
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedRange() {
|
||||
const rangeElement = document.getElementById('selectedRange');
|
||||
const rangeText = document.getElementById('rangeText');
|
||||
|
||||
if (this.selectedStartDate && this.selectedEndDate) {
|
||||
const startStr = this.formatDate(this.selectedStartDate);
|
||||
const endStr = this.formatDate(this.selectedEndDate);
|
||||
const daysDiff = Math.ceil((this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
rangeText.textContent = `${startStr} ~ ${endStr} (${daysDiff}일)`;
|
||||
rangeElement.style.display = 'block';
|
||||
} else if (this.selectedStartDate) {
|
||||
rangeText.textContent = `시작일: ${this.formatDate(this.selectedStartDate)} (종료일을 선택하세요)`;
|
||||
rangeElement.style.display = 'block';
|
||||
} else {
|
||||
rangeElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selectedStartDate = null;
|
||||
this.selectedEndDate = null;
|
||||
this.isSelecting = false;
|
||||
this.updateCalendar();
|
||||
this.updateSelectedRange();
|
||||
}
|
||||
|
||||
isDateInRange(date) {
|
||||
if (!this.selectedStartDate) return false;
|
||||
if (!this.selectedEndDate) return this.isSameDate(date, this.selectedStartDate);
|
||||
|
||||
return date >= this.selectedStartDate && date <= this.selectedEndDate;
|
||||
}
|
||||
|
||||
isSameDate(date1, date2) {
|
||||
return date1.toDateString() === date2.toDateString();
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// 외부에서 호출할 수 있는 메서드들
|
||||
getSelectedRange() {
|
||||
return {
|
||||
startDate: this.selectedStartDate,
|
||||
endDate: this.selectedEndDate
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedRange(startDate, endDate) {
|
||||
this.selectedStartDate = startDate;
|
||||
this.selectedEndDate = endDate;
|
||||
this.updateCalendar();
|
||||
this.updateSelectedRange();
|
||||
}
|
||||
}
|
||||
|
||||
// 전역으로 노출
|
||||
window.MobileCalendar = MobileCalendar;
|
||||
319
system3-nonconformance/web/public/static/js/core/auth-manager.js
Normal file
319
system3-nonconformance/web/public/static/js/core/auth-manager.js
Normal file
@@ -0,0 +1,319 @@
|
||||
// 서비스 워커 해제 (push-sw.js 제외)
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
registrations.forEach(function(registration) {
|
||||
if (!registration.active || !registration.active.scriptURL.includes('push-sw.js')) {
|
||||
registration.unregister();
|
||||
}
|
||||
});
|
||||
});
|
||||
if (typeof caches !== 'undefined') {
|
||||
caches.keys().then(function(names) {
|
||||
names.forEach(function(name) { caches.delete(name); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 중앙화된 인증 관리자
|
||||
* 페이지 간 이동 시 불필요한 API 호출을 방지하고 인증 상태를 효율적으로 관리
|
||||
*/
|
||||
class AuthManager {
|
||||
constructor() {
|
||||
this.currentUser = null;
|
||||
this.isAuthenticated = false;
|
||||
this.lastAuthCheck = null;
|
||||
this.authCheckInterval = 5 * 60 * 1000; // 5분마다 토큰 유효성 체크
|
||||
this.listeners = new Set();
|
||||
|
||||
// 초기화
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
init() {
|
||||
|
||||
// localStorage에서 사용자 정보 복원
|
||||
this.restoreUserFromStorage();
|
||||
|
||||
// 토큰 만료 체크 타이머 설정
|
||||
this.setupTokenExpiryCheck();
|
||||
|
||||
// 페이지 가시성 변경 시 토큰 체크
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && this.shouldCheckAuth()) {
|
||||
this.refreshAuth();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키에서 값 읽기
|
||||
*/
|
||||
_cookieGet(name) {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 삭제
|
||||
*/
|
||||
_cookieRemove(name) {
|
||||
let cookie = name + '=; path=/; max-age=0';
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSO 토큰 가져오기 (쿠키 우선, localStorage 폴백)
|
||||
*/
|
||||
_getToken() {
|
||||
return this._cookieGet('sso_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* SSO 사용자 정보 가져오기 (쿠키 우선, localStorage 폴백)
|
||||
*/
|
||||
_getUser() {
|
||||
const ssoUser = this._cookieGet('sso_user');
|
||||
if (ssoUser && ssoUser !== 'undefined' && ssoUser !== 'null') {
|
||||
try { return JSON.parse(ssoUser); } catch(e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중앙 로그인 URL
|
||||
*/
|
||||
_getLoginUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return window.location.protocol + '//tkfb.technicalkorea.net/dashboard?redirect=' + encodeURIComponent(window.location.href);
|
||||
}
|
||||
return window.location.protocol + '//' + hostname + ':30000/dashboard?redirect=' + encodeURIComponent(window.location.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소에서 사용자 정보 복원 (SSO 쿠키 + localStorage)
|
||||
*/
|
||||
restoreUserFromStorage() {
|
||||
// 쿠키 우선 검증: 쿠키 없고 localStorage에만 토큰이 있으면 정리
|
||||
const cookieToken = this._cookieGet('sso_token');
|
||||
const localToken = localStorage.getItem('sso_token');
|
||||
if (!cookieToken && localToken) {
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
|
||||
'currentUser','current_user','userInfo','userPageAccess'].forEach(k => localStorage.removeItem(k));
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this._getToken();
|
||||
const user = this._getUser();
|
||||
|
||||
if (token && user) {
|
||||
try {
|
||||
this.currentUser = user;
|
||||
this.isAuthenticated = true;
|
||||
this.lastAuthCheck = Date.now();
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 복원 실패:', error);
|
||||
this.clearAuth();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증이 필요한지 확인
|
||||
*/
|
||||
shouldCheckAuth() {
|
||||
if (!this.isAuthenticated) return true;
|
||||
if (!this.lastAuthCheck) return true;
|
||||
|
||||
const timeSinceLastCheck = Date.now() - this.lastAuthCheck;
|
||||
return timeSinceLastCheck > this.authCheckInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 상태 확인 (필요시에만 API 호출)
|
||||
*/
|
||||
async checkAuth() {
|
||||
const token = this._getToken();
|
||||
if (!token) {
|
||||
this.clearAuth();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 최근에 체크했으면 캐시된 정보 사용
|
||||
if (this.isAuthenticated && !this.shouldCheckAuth()) {
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
return await this.refreshAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* 강제로 인증 정보 새로고침 (API 호출)
|
||||
*/
|
||||
async refreshAuth() {
|
||||
try {
|
||||
await this.waitForAPI();
|
||||
|
||||
const user = await AuthAPI.getCurrentUser();
|
||||
|
||||
this.currentUser = user;
|
||||
this.isAuthenticated = true;
|
||||
this.lastAuthCheck = Date.now();
|
||||
|
||||
// localStorage 업데이트 (쿠키 소실 대비 백업)
|
||||
const token = this._getToken();
|
||||
if (token) localStorage.setItem('sso_token', token);
|
||||
localStorage.setItem('sso_user', JSON.stringify(user));
|
||||
|
||||
this.notifyListeners('auth-success', user);
|
||||
return user;
|
||||
|
||||
} catch (error) {
|
||||
console.error('인증 실패:', error);
|
||||
this.clearAuth();
|
||||
this.notifyListeners('auth-failed', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 로드 대기
|
||||
*/
|
||||
async waitForAPI() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50;
|
||||
|
||||
while (typeof AuthAPI === 'undefined' && attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (typeof AuthAPI === 'undefined') {
|
||||
throw new Error('AuthAPI를 로드할 수 없습니다');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 정보 클리어
|
||||
*/
|
||||
clearAuth() {
|
||||
this.currentUser = null;
|
||||
this.isAuthenticated = false;
|
||||
this.lastAuthCheck = null;
|
||||
|
||||
// SSO 쿠키 삭제
|
||||
this._cookieRemove('sso_token');
|
||||
this._cookieRemove('sso_user');
|
||||
this._cookieRemove('sso_refresh_token');
|
||||
|
||||
// localStorage 삭제 (전 시스템 키 통일)
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(k => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
|
||||
this.notifyListeners('auth-cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 처리
|
||||
*/
|
||||
async login(username, password) {
|
||||
try {
|
||||
await this.waitForAPI();
|
||||
const data = await AuthAPI.login(username, password);
|
||||
|
||||
this.currentUser = data.user;
|
||||
this.isAuthenticated = true;
|
||||
this.lastAuthCheck = Date.now();
|
||||
|
||||
// localStorage 저장 (sso_token/sso_user로 통일)
|
||||
localStorage.setItem('sso_token', data.access_token);
|
||||
localStorage.setItem('sso_user', JSON.stringify(data.user));
|
||||
|
||||
this.notifyListeners('login-success', data.user);
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('로그인 실패:', error);
|
||||
this.clearAuth();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 처리
|
||||
*/
|
||||
logout() {
|
||||
this.clearAuth();
|
||||
this.notifyListeners('logout');
|
||||
window.location.href = this._getLoginUrl() + '&logout=1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료 체크 타이머 설정
|
||||
*/
|
||||
setupTokenExpiryCheck() {
|
||||
setInterval(() => {
|
||||
if (this.isAuthenticated) {
|
||||
this.refreshAuth().then(user => {
|
||||
if (!user) {
|
||||
// 인증 실패 — clearAuth()는 refreshAuth 내부에서 이미 처리됨
|
||||
this.notifyListeners('token-expired');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 등록
|
||||
*/
|
||||
addEventListener(callback) {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 제거
|
||||
*/
|
||||
removeEventListener(callback) {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스너들에게 알림
|
||||
*/
|
||||
notifyListeners(event, data = null) {
|
||||
this.listeners.forEach(callback => {
|
||||
try {
|
||||
callback(event, data);
|
||||
} catch (error) {
|
||||
console.error('리스너 콜백 오류:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 반환
|
||||
*/
|
||||
getCurrentUser() {
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 상태 반환
|
||||
*/
|
||||
isLoggedIn() {
|
||||
return this.isAuthenticated && !!this.currentUser;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.authManager = new AuthManager();
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* 키보드 단축키 관리자
|
||||
* 전역 키보드 단축키를 관리하고 사용자 경험을 향상시킵니다.
|
||||
*/
|
||||
|
||||
class KeyboardShortcutManager {
|
||||
constructor() {
|
||||
this.shortcuts = new Map();
|
||||
this.isEnabled = true;
|
||||
this.helpModalVisible = false;
|
||||
this.currentUser = null;
|
||||
|
||||
// 기본 단축키 등록
|
||||
this.registerDefaultShortcuts();
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 단축키 등록
|
||||
*/
|
||||
registerDefaultShortcuts() {
|
||||
// 전역 단축키
|
||||
this.register('?', () => this.showHelpModal(), '도움말 표시');
|
||||
this.register('Escape', () => this.handleEscape(), '모달/메뉴 닫기');
|
||||
|
||||
// 네비게이션 단축키
|
||||
this.register('g h', () => this.navigateToPage('/index.html', 'issues_create'), '홈 (부적합 등록)');
|
||||
this.register('g v', () => this.navigateToPage('/issue-view.html', 'issues_view'), '부적합 조회');
|
||||
this.register('g r', () => this.navigateToPage('/reports.html', 'reports'), '보고서');
|
||||
|
||||
// 액션 단축키
|
||||
this.register('n', () => this.triggerNewAction(), '새 항목 생성');
|
||||
this.register('s', () => this.triggerSaveAction(), '저장');
|
||||
this.register('r', () => this.triggerRefreshAction(), '새로고침');
|
||||
this.register('f', () => this.focusSearchField(), '검색 포커스');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 단축키 등록
|
||||
* @param {string} combination - 키 조합 (예: 'ctrl+s', 'g h')
|
||||
* @param {function} callback - 실행할 함수
|
||||
* @param {string} description - 설명
|
||||
* @param {object} options - 옵션
|
||||
*/
|
||||
register(combination, callback, description, options = {}) {
|
||||
const normalizedCombo = this.normalizeKeyCombination(combination);
|
||||
|
||||
this.shortcuts.set(normalizedCombo, {
|
||||
callback,
|
||||
description,
|
||||
requiresAuth: options.requiresAuth !== false,
|
||||
adminOnly: options.adminOnly || false,
|
||||
pageSpecific: options.pageSpecific || null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 조합 정규화
|
||||
*/
|
||||
normalizeKeyCombination(combination) {
|
||||
return combination
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length > 0)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 바인딩
|
||||
*/
|
||||
bindEvents() {
|
||||
let keySequence = [];
|
||||
let sequenceTimer = null;
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
// 입력 필드에서는 일부 단축키만 허용
|
||||
if (this.isInputField(e.target)) {
|
||||
this.handleInputFieldShortcuts(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 키 조합 생성
|
||||
const keyCombo = this.createKeyCombo(e);
|
||||
|
||||
// 시퀀스 타이머 리셋
|
||||
if (sequenceTimer) {
|
||||
clearTimeout(sequenceTimer);
|
||||
}
|
||||
|
||||
// 단일 키 단축키 확인
|
||||
if (this.handleShortcut(keyCombo, e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 시퀀스 키 처리
|
||||
keySequence.push(keyCombo);
|
||||
|
||||
// 시퀀스 단축키 확인
|
||||
const sequenceCombo = keySequence.join(' ');
|
||||
if (this.handleShortcut(sequenceCombo, e)) {
|
||||
keySequence = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 시퀀스 타이머 설정 (1초 후 리셋)
|
||||
sequenceTimer = setTimeout(() => {
|
||||
keySequence = [];
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 조합 생성
|
||||
*/
|
||||
createKeyCombo(event) {
|
||||
const parts = [];
|
||||
|
||||
if (event.ctrlKey) parts.push('ctrl');
|
||||
if (event.altKey) parts.push('alt');
|
||||
if (event.shiftKey) parts.push('shift');
|
||||
if (event.metaKey) parts.push('meta');
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// 특수 키 처리
|
||||
const specialKeys = {
|
||||
' ': 'space',
|
||||
'enter': 'enter',
|
||||
'escape': 'escape',
|
||||
'tab': 'tab',
|
||||
'backspace': 'backspace',
|
||||
'delete': 'delete',
|
||||
'arrowup': 'up',
|
||||
'arrowdown': 'down',
|
||||
'arrowleft': 'left',
|
||||
'arrowright': 'right'
|
||||
};
|
||||
|
||||
const normalizedKey = specialKeys[key] || key;
|
||||
parts.push(normalizedKey);
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* 단축키 처리
|
||||
*/
|
||||
handleShortcut(combination, event) {
|
||||
const shortcut = this.shortcuts.get(combination);
|
||||
|
||||
if (!shortcut) return false;
|
||||
|
||||
// 권한 확인
|
||||
if (shortcut.requiresAuth && !this.currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shortcut.adminOnly && this.currentUser?.role !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 페이지별 단축키 확인
|
||||
if (shortcut.pageSpecific && !this.isCurrentPage(shortcut.pageSpecific)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 기본 동작 방지
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// 콜백 실행
|
||||
try {
|
||||
shortcut.callback(event);
|
||||
} catch (error) {
|
||||
console.error('단축키 실행 실패:', combination, error);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 필드 확인
|
||||
*/
|
||||
isInputField(element) {
|
||||
const inputTypes = ['input', 'textarea', 'select'];
|
||||
const contentEditable = element.contentEditable === 'true';
|
||||
|
||||
return inputTypes.includes(element.tagName.toLowerCase()) || contentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 필드에서의 단축키 처리
|
||||
*/
|
||||
handleInputFieldShortcuts(event) {
|
||||
const keyCombo = this.createKeyCombo(event);
|
||||
|
||||
// 입력 필드에서 허용되는 단축키
|
||||
const allowedInInput = ['escape', 'ctrl+s', 'ctrl+enter'];
|
||||
|
||||
if (allowedInInput.includes(keyCombo)) {
|
||||
this.handleShortcut(keyCombo, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지 확인
|
||||
*/
|
||||
isCurrentPage(pageId) {
|
||||
return window.commonHeader?.currentPage === pageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 네비게이션
|
||||
*/
|
||||
navigateToPage(url, pageId) {
|
||||
// 권한 확인
|
||||
if (pageId && window.canAccessPage && !window.canAccessPage(pageId)) {
|
||||
this.showNotification('해당 페이지에 접근할 권한이 없습니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 페이지와 같으면 무시
|
||||
if (window.location.pathname === url) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 부드러운 전환
|
||||
if (window.CommonHeader) {
|
||||
window.CommonHeader.navigateToPage(
|
||||
{ preventDefault: () => {}, stopPropagation: () => {} },
|
||||
url,
|
||||
pageId
|
||||
);
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 항목 생성 액션
|
||||
*/
|
||||
triggerNewAction() {
|
||||
const newButtons = [
|
||||
'button[onclick*="showAddModal"]',
|
||||
'button[onclick*="addNew"]',
|
||||
'#addBtn',
|
||||
'#add-btn',
|
||||
'.btn-add',
|
||||
'button:contains("추가")',
|
||||
'button:contains("등록")',
|
||||
'button:contains("새")'
|
||||
];
|
||||
|
||||
for (const selector of newButtons) {
|
||||
const button = document.querySelector(selector);
|
||||
if (button && !button.disabled) {
|
||||
button.click();
|
||||
this.showNotification('새 항목 생성', 'info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.showNotification('새 항목 생성 버튼을 찾을 수 없습니다.', 'warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 액션
|
||||
*/
|
||||
triggerSaveAction() {
|
||||
const saveButtons = [
|
||||
'button[type="submit"]',
|
||||
'button[onclick*="save"]',
|
||||
'#saveBtn',
|
||||
'#save-btn',
|
||||
'.btn-save',
|
||||
'button:contains("저장")',
|
||||
'button:contains("등록")'
|
||||
];
|
||||
|
||||
for (const selector of saveButtons) {
|
||||
const button = document.querySelector(selector);
|
||||
if (button && !button.disabled) {
|
||||
button.click();
|
||||
this.showNotification('저장 실행', 'success');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.showNotification('저장 버튼을 찾을 수 없습니다.', 'warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로고침 액션
|
||||
*/
|
||||
triggerRefreshAction() {
|
||||
const refreshButtons = [
|
||||
'button[onclick*="load"]',
|
||||
'button[onclick*="refresh"]',
|
||||
'#refreshBtn',
|
||||
'#refresh-btn',
|
||||
'.btn-refresh'
|
||||
];
|
||||
|
||||
for (const selector of refreshButtons) {
|
||||
const button = document.querySelector(selector);
|
||||
if (button && !button.disabled) {
|
||||
button.click();
|
||||
this.showNotification('새로고침 실행', 'info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 새로고침
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 필드 포커스
|
||||
*/
|
||||
focusSearchField() {
|
||||
const searchFields = [
|
||||
'input[type="search"]',
|
||||
'input[placeholder*="검색"]',
|
||||
'input[placeholder*="찾기"]',
|
||||
'#searchInput',
|
||||
'#search',
|
||||
'.search-input'
|
||||
];
|
||||
|
||||
for (const selector of searchFields) {
|
||||
const field = document.querySelector(selector);
|
||||
if (field) {
|
||||
field.focus();
|
||||
field.select();
|
||||
this.showNotification('검색 필드 포커스', 'info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.showNotification('검색 필드를 찾을 수 없습니다.', 'warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape 키 처리
|
||||
*/
|
||||
handleEscape() {
|
||||
// 모달 닫기
|
||||
const modals = document.querySelectorAll('.modal, [id*="modal"], [class*="modal"]');
|
||||
for (const modal of modals) {
|
||||
if (!modal.classList.contains('hidden') && modal.style.display !== 'none') {
|
||||
modal.classList.add('hidden');
|
||||
this.showNotification('모달 닫기', 'info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 드롭다운 메뉴 닫기
|
||||
const dropdowns = document.querySelectorAll('[id*="menu"], [class*="dropdown"]');
|
||||
for (const dropdown of dropdowns) {
|
||||
if (!dropdown.classList.contains('hidden')) {
|
||||
dropdown.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 포커스 해제
|
||||
if (document.activeElement && document.activeElement !== document.body) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 모달 표시
|
||||
*/
|
||||
showHelpModal() {
|
||||
if (this.helpModalVisible) {
|
||||
this.hideHelpModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = this.createHelpModal();
|
||||
document.body.appendChild(modal);
|
||||
this.helpModalVisible = true;
|
||||
|
||||
// 외부 클릭으로 닫기
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
this.hideHelpModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 모달 생성
|
||||
*/
|
||||
createHelpModal() {
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'keyboard-shortcuts-modal';
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
|
||||
const shortcuts = this.getAvailableShortcuts();
|
||||
const shortcutGroups = this.groupShortcuts(shortcuts);
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
<i class="fas fa-keyboard mr-3 text-blue-600"></i>
|
||||
키보드 단축키
|
||||
</h2>
|
||||
<button onclick="keyboardShortcuts.hideHelpModal()"
|
||||
class="text-gray-400 hover:text-gray-600 text-2xl">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
${Object.entries(shortcutGroups).map(([group, items]) => `
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4 border-b pb-2">
|
||||
${group}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
${items.map(item => `
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">${item.description}</span>
|
||||
<div class="flex space-x-1">
|
||||
${item.keys.map(key => `
|
||||
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">
|
||||
${key}
|
||||
</kbd>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 p-4 bg-blue-50 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-blue-600 mt-1 mr-3"></i>
|
||||
<div>
|
||||
<h4 class="font-semibold text-blue-900 mb-2">사용 팁</h4>
|
||||
<ul class="text-blue-800 text-sm space-y-1">
|
||||
<li>• 입력 필드에서는 일부 단축키만 작동합니다.</li>
|
||||
<li>• 'g' 키를 누른 후 다른 키를 눌러 페이지를 이동할 수 있습니다.</li>
|
||||
<li>• ESC 키로 모달이나 메뉴를 닫을 수 있습니다.</li>
|
||||
<li>• '?' 키로 언제든 이 도움말을 볼 수 있습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 단축키 가져오기
|
||||
*/
|
||||
getAvailableShortcuts() {
|
||||
const available = [];
|
||||
|
||||
for (const [combination, shortcut] of this.shortcuts) {
|
||||
// 권한 확인
|
||||
if (shortcut.requiresAuth && !this.currentUser) continue;
|
||||
if (shortcut.adminOnly && this.currentUser?.role !== 'admin') continue;
|
||||
|
||||
available.push({
|
||||
combination,
|
||||
description: shortcut.description,
|
||||
keys: this.formatKeyCombo(combination)
|
||||
});
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단축키 그룹화
|
||||
*/
|
||||
groupShortcuts(shortcuts) {
|
||||
const groups = {
|
||||
'네비게이션': [],
|
||||
'액션': [],
|
||||
'전역': []
|
||||
};
|
||||
|
||||
shortcuts.forEach(shortcut => {
|
||||
if (shortcut.combination.startsWith('g ')) {
|
||||
groups['네비게이션'].push(shortcut);
|
||||
} else if (['n', 's', 'r', 'f'].includes(shortcut.combination)) {
|
||||
groups['액션'].push(shortcut);
|
||||
} else {
|
||||
groups['전역'].push(shortcut);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 조합 포맷팅
|
||||
*/
|
||||
formatKeyCombo(combination) {
|
||||
return combination
|
||||
.split(' ')
|
||||
.map(part => {
|
||||
return part
|
||||
.split('+')
|
||||
.map(key => {
|
||||
const keyNames = {
|
||||
'ctrl': 'Ctrl',
|
||||
'alt': 'Alt',
|
||||
'shift': 'Shift',
|
||||
'meta': 'Cmd',
|
||||
'space': 'Space',
|
||||
'enter': 'Enter',
|
||||
'escape': 'Esc',
|
||||
'tab': 'Tab'
|
||||
};
|
||||
return keyNames[key] || key.toUpperCase();
|
||||
})
|
||||
.join(' + ');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 모달 숨기기
|
||||
*/
|
||||
hideHelpModal() {
|
||||
const modal = document.getElementById('keyboard-shortcuts-modal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
this.helpModalVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 표시
|
||||
*/
|
||||
showNotification(message, type = 'info') {
|
||||
// 기존 알림 제거
|
||||
const existing = document.getElementById('shortcut-notification');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.id = 'shortcut-notification';
|
||||
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg shadow-lg z-50 transition-all duration-300 ${this.getNotificationClass(type)}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// 3초 후 자동 제거
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 클래스 가져오기
|
||||
*/
|
||||
getNotificationClass(type) {
|
||||
const classes = {
|
||||
'info': 'bg-blue-600 text-white',
|
||||
'success': 'bg-green-600 text-white',
|
||||
'warning': 'bg-yellow-600 text-white',
|
||||
'error': 'bg-red-600 text-white'
|
||||
};
|
||||
return classes[type] || classes.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 설정
|
||||
*/
|
||||
setUser(user) {
|
||||
this.currentUser = user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단축키 활성화/비활성화
|
||||
*/
|
||||
setEnabled(enabled) {
|
||||
this.isEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단축키 제거
|
||||
*/
|
||||
unregister(combination) {
|
||||
const normalizedCombo = this.normalizeKeyCombination(combination);
|
||||
return this.shortcuts.delete(normalizedCombo);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스
|
||||
window.keyboardShortcuts = new KeyboardShortcutManager();
|
||||
362
system3-nonconformance/web/public/static/js/core/page-manager.js
Normal file
362
system3-nonconformance/web/public/static/js/core/page-manager.js
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 페이지 관리자
|
||||
* 모듈화된 페이지들의 생명주기를 관리하고 부드러운 전환을 제공
|
||||
*/
|
||||
|
||||
class PageManager {
|
||||
constructor() {
|
||||
this.currentPage = null;
|
||||
this.loadedModules = new Map();
|
||||
this.pageHistory = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
* @param {string} pageId - 페이지 식별자
|
||||
* @param {Object} options - 초기화 옵션
|
||||
*/
|
||||
async initializePage(pageId, options = {}) {
|
||||
try {
|
||||
// 로딩 표시
|
||||
this.showPageLoader();
|
||||
|
||||
// 사용자 인증 확인
|
||||
const user = await this.checkAuthentication();
|
||||
if (!user) return;
|
||||
|
||||
// 공통 헤더 초기화
|
||||
await this.initializeCommonHeader(user, pageId);
|
||||
|
||||
// 페이지별 권한 체크
|
||||
if (!this.checkPagePermission(pageId, user)) {
|
||||
this.redirectToAccessiblePage();
|
||||
return;
|
||||
}
|
||||
|
||||
// 페이지 모듈 로드 및 초기화
|
||||
await this.loadPageModule(pageId, options);
|
||||
|
||||
// 페이지 히스토리 업데이트
|
||||
this.updatePageHistory(pageId);
|
||||
|
||||
// 로딩 숨기기
|
||||
this.hidePageLoader();
|
||||
|
||||
} catch (error) {
|
||||
console.error('페이지 초기화 실패:', error);
|
||||
this.showErrorPage(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 인증 확인
|
||||
*/
|
||||
async checkAuthentication() {
|
||||
const token = localStorage.getItem('sso_token');
|
||||
if (!token) {
|
||||
window.location.href = '/index.html';
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// API가 로드될 때까지 대기
|
||||
await this.waitForAPI();
|
||||
|
||||
const user = await AuthAPI.getCurrentUser();
|
||||
localStorage.setItem('sso_user', JSON.stringify(user));
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('인증 실패:', error);
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
window.location.href = '/index.html';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 로드 대기
|
||||
*/
|
||||
async waitForAPI() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50;
|
||||
|
||||
while (!window.AuthAPI && attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!window.AuthAPI) {
|
||||
throw new Error('API를 로드할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 헤더 초기화
|
||||
*/
|
||||
async initializeCommonHeader(user, pageId) {
|
||||
// 권한 시스템 초기화
|
||||
if (window.pagePermissionManager) {
|
||||
window.pagePermissionManager.setUser(user);
|
||||
}
|
||||
|
||||
// 공통 헤더 초기화
|
||||
if (window.commonHeader) {
|
||||
await window.commonHeader.init(user, pageId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 권한 체크
|
||||
*/
|
||||
checkPagePermission(pageId, user) {
|
||||
// admin은 모든 페이지 접근 가능
|
||||
if (user.role === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 권한 시스템이 로드되지 않았으면 기본 페이지만 허용
|
||||
if (!window.canAccessPage) {
|
||||
return ['issues_create', 'issues_view'].includes(pageId);
|
||||
}
|
||||
|
||||
return window.canAccessPage(pageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 가능한 페이지로 리다이렉트
|
||||
*/
|
||||
redirectToAccessiblePage() {
|
||||
alert('이 페이지에 접근할 권한이 없습니다.');
|
||||
|
||||
// 기본적으로 접근 가능한 페이지로 이동
|
||||
if (window.canAccessPage && window.canAccessPage('issues_view')) {
|
||||
window.location.href = '/issue-view.html';
|
||||
} else {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 모듈 로드
|
||||
*/
|
||||
async loadPageModule(pageId, options) {
|
||||
// 이미 로드된 모듈이 있으면 재사용
|
||||
if (this.loadedModules.has(pageId)) {
|
||||
const module = this.loadedModules.get(pageId);
|
||||
if (module.reinitialize) {
|
||||
await module.reinitialize(options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 페이지별 모듈 로드
|
||||
const module = await this.createPageModule(pageId, options);
|
||||
if (module) {
|
||||
this.loadedModules.set(pageId, module);
|
||||
this.currentPage = pageId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 모듈 생성
|
||||
*/
|
||||
async createPageModule(pageId, options) {
|
||||
switch (pageId) {
|
||||
case 'issues_create':
|
||||
return new IssuesCreateModule(options);
|
||||
case 'issues_view':
|
||||
return new IssuesViewModule(options);
|
||||
case 'issues_manage':
|
||||
return new IssuesManageModule(options);
|
||||
case 'reports':
|
||||
return new ReportsModule(options);
|
||||
default:
|
||||
console.warn(`알 수 없는 페이지 ID: ${pageId}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 히스토리 업데이트
|
||||
*/
|
||||
updatePageHistory(pageId) {
|
||||
this.pageHistory.push({
|
||||
pageId,
|
||||
timestamp: new Date(),
|
||||
url: window.location.href
|
||||
});
|
||||
|
||||
// 히스토리 크기 제한 (최대 10개)
|
||||
if (this.pageHistory.length > 10) {
|
||||
this.pageHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 로더 표시
|
||||
*/
|
||||
showPageLoader() {
|
||||
const existingLoader = document.getElementById('page-loader');
|
||||
if (existingLoader) return;
|
||||
|
||||
const loader = document.createElement('div');
|
||||
loader.id = 'page-loader';
|
||||
loader.className = 'fixed inset-0 bg-white bg-opacity-90 flex items-center justify-center z-50';
|
||||
loader.innerHTML = `
|
||||
<div class="text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
||||
<p class="text-lg font-medium text-gray-700">페이지를 로드하는 중...</p>
|
||||
<p class="text-sm text-gray-500 mt-1">잠시만 기다려주세요</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 로더 숨기기
|
||||
*/
|
||||
hidePageLoader() {
|
||||
const loader = document.getElementById('page-loader');
|
||||
if (loader) {
|
||||
loader.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 페이지 표시
|
||||
*/
|
||||
showErrorPage(error) {
|
||||
this.hidePageLoader();
|
||||
|
||||
const errorContainer = document.createElement('div');
|
||||
errorContainer.className = 'fixed inset-0 bg-gray-50 flex items-center justify-center z-50';
|
||||
errorContainer.innerHTML = `
|
||||
<div class="text-center max-w-md mx-auto p-8">
|
||||
<div class="mb-6">
|
||||
<i class="fas fa-exclamation-triangle text-6xl text-red-500"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">페이지 로드 실패</h2>
|
||||
<p class="text-gray-600 mb-6">${error.message || '알 수 없는 오류가 발생했습니다.'}</p>
|
||||
<div class="space-x-4">
|
||||
<button onclick="window.location.reload()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
다시 시도
|
||||
</button>
|
||||
<button onclick="window.location.href='/index.html'"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
|
||||
홈으로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(errorContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 정리
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.currentPage && this.loadedModules.has(this.currentPage)) {
|
||||
const module = this.loadedModules.get(this.currentPage);
|
||||
if (module.cleanup) {
|
||||
module.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 페이지 모듈 클래스
|
||||
* 모든 페이지 모듈이 상속받아야 하는 기본 클래스
|
||||
*/
|
||||
class BasePageModule {
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
this.initialized = false;
|
||||
this.eventListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 초기화 (하위 클래스에서 구현)
|
||||
*/
|
||||
async initialize() {
|
||||
throw new Error('initialize 메서드를 구현해야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 재초기화
|
||||
*/
|
||||
async reinitialize(options = {}) {
|
||||
this.cleanup();
|
||||
this.options = { ...this.options, ...options };
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 등록 (자동 정리를 위해)
|
||||
*/
|
||||
addEventListener(element, event, handler) {
|
||||
element.addEventListener(event, handler);
|
||||
this.eventListeners.push({ element, event, handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 정리
|
||||
*/
|
||||
cleanup() {
|
||||
// 등록된 이벤트 리스너 제거
|
||||
this.eventListeners.forEach(({ element, event, handler }) => {
|
||||
element.removeEventListener(event, handler);
|
||||
});
|
||||
this.eventListeners = [];
|
||||
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 표시
|
||||
*/
|
||||
showLoading(container, message = '로딩 중...') {
|
||||
if (typeof container === 'string') {
|
||||
container = document.getElementById(container);
|
||||
}
|
||||
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-3"></div>
|
||||
<p class="text-gray-600">${message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 표시
|
||||
*/
|
||||
showError(container, message = '오류가 발생했습니다.') {
|
||||
if (typeof container === 'string') {
|
||||
container = document.getElementById(container);
|
||||
}
|
||||
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-500 mb-3"></i>
|
||||
<p class="text-gray-600">${message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스
|
||||
window.pageManager = new PageManager();
|
||||
window.BasePageModule = BasePageModule;
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 페이지 프리로더
|
||||
* 사용자가 방문할 가능성이 높은 페이지들을 미리 로드하여 성능 향상
|
||||
*/
|
||||
|
||||
class PagePreloader {
|
||||
constructor() {
|
||||
this.preloadedPages = new Set();
|
||||
this.preloadQueue = [];
|
||||
this.isPreloading = false;
|
||||
this.preloadCache = new Map();
|
||||
this.resourceCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리로더 초기화
|
||||
*/
|
||||
init() {
|
||||
// 유휴 시간에 프리로딩 시작
|
||||
this.schedulePreloading();
|
||||
|
||||
// 링크 호버 시 프리로딩
|
||||
this.setupHoverPreloading();
|
||||
|
||||
// 기존 서비스 워커 해제 (캐시 문제 방지)
|
||||
this.unregisterServiceWorker();
|
||||
}
|
||||
|
||||
/**
|
||||
* 우선순위 기반 프리로딩 스케줄링
|
||||
*/
|
||||
schedulePreloading() {
|
||||
// 현재 사용자 권한에 따른 접근 가능한 페이지들
|
||||
const accessiblePages = this.getAccessiblePages();
|
||||
|
||||
// 우선순위 설정
|
||||
const priorityPages = this.getPriorityPages(accessiblePages);
|
||||
|
||||
// 유휴 시간에 프리로딩 시작
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => {
|
||||
this.startPreloading(priorityPages);
|
||||
}, { timeout: 2000 });
|
||||
} else {
|
||||
// requestIdleCallback 미지원 브라우저
|
||||
setTimeout(() => {
|
||||
this.startPreloading(priorityPages);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 가능한 페이지 목록 가져오기
|
||||
*/
|
||||
getAccessiblePages() {
|
||||
const allPages = [
|
||||
{ id: 'issues_create', url: '/index.html', priority: 1 },
|
||||
{ id: 'issues_view', url: '/issue-view.html', priority: 1 },
|
||||
{ id: 'issues_manage', url: '/index.html#list', priority: 2 },
|
||||
{ id: 'reports', url: '/reports.html', priority: 3 }
|
||||
];
|
||||
|
||||
// 권한 체크
|
||||
return allPages.filter(page => {
|
||||
if (!window.canAccessPage) return false;
|
||||
return window.canAccessPage(page.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 우선순위 기반 페이지 정렬
|
||||
*/
|
||||
getPriorityPages(pages) {
|
||||
return pages
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.slice(0, 3); // 최대 3개 페이지만 프리로드
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리로딩 시작
|
||||
*/
|
||||
async startPreloading(pages) {
|
||||
if (this.isPreloading) return;
|
||||
|
||||
this.isPreloading = true;
|
||||
|
||||
for (const page of pages) {
|
||||
if (this.preloadedPages.has(page.url)) continue;
|
||||
|
||||
try {
|
||||
await this.preloadPage(page);
|
||||
|
||||
// 네트워크 상태 확인 (느린 연결에서는 중단)
|
||||
if (this.isSlowConnection()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// CPU 부하 방지를 위한 딜레이
|
||||
await this.delay(500);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('프리로딩 실패:', page.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.isPreloading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 페이지 프리로드
|
||||
*/
|
||||
async preloadPage(page) {
|
||||
try {
|
||||
// HTML 프리로드
|
||||
const htmlResponse = await fetch(page.url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'text/html' }
|
||||
});
|
||||
|
||||
if (htmlResponse.ok) {
|
||||
const html = await htmlResponse.text();
|
||||
this.preloadCache.set(page.url, html);
|
||||
|
||||
// 페이지 내 리소스 추출 및 프리로드
|
||||
await this.preloadPageResources(html, page.url);
|
||||
|
||||
this.preloadedPages.add(page.url);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`프리로드 실패: ${page.id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 리소스 프리로드 (CSS, JS)
|
||||
*/
|
||||
async preloadPageResources(html, baseUrl) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// CSS 파일 프리로드
|
||||
const cssLinks = doc.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (const link of cssLinks) {
|
||||
const href = this.resolveUrl(link.href, baseUrl);
|
||||
if (!this.resourceCache.has(href)) {
|
||||
this.preloadResource(href, 'style');
|
||||
}
|
||||
}
|
||||
|
||||
// JS 파일 프리로드 (중요한 것만)
|
||||
const scriptTags = doc.querySelectorAll('script[src]');
|
||||
for (const script of scriptTags) {
|
||||
const src = this.resolveUrl(script.src, baseUrl);
|
||||
if (this.isImportantScript(src) && !this.resourceCache.has(src)) {
|
||||
this.preloadResource(src, 'script');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 프리로드
|
||||
*/
|
||||
preloadResource(url, type) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'preload';
|
||||
link.href = url;
|
||||
link.as = type;
|
||||
|
||||
link.onload = () => {
|
||||
this.resourceCache.set(url, true);
|
||||
};
|
||||
|
||||
link.onerror = () => {
|
||||
console.warn('리소스 프리로드 실패:', url);
|
||||
};
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중요한 스크립트 판별
|
||||
*/
|
||||
isImportantScript(src) {
|
||||
const importantScripts = [
|
||||
'api.js',
|
||||
'permissions.js',
|
||||
'common-header.js',
|
||||
'page-manager.js'
|
||||
];
|
||||
|
||||
return importantScripts.some(script => src.includes(script));
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 해결
|
||||
*/
|
||||
resolveUrl(url, baseUrl) {
|
||||
if (url.startsWith('http') || url.startsWith('//')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const base = new URL(baseUrl, window.location.origin);
|
||||
return new URL(url, base).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* 호버 시 프리로딩 설정
|
||||
*/
|
||||
setupHoverPreloading() {
|
||||
let hoverTimeout;
|
||||
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const link = e.target.closest('a[href]');
|
||||
if (!link) return;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||
|
||||
// 300ms 후 프리로드 (실제 클릭 의도 확인)
|
||||
hoverTimeout = setTimeout(() => {
|
||||
this.preloadOnHover(href);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout);
|
||||
hoverTimeout = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 호버 시 프리로드
|
||||
*/
|
||||
async preloadOnHover(url) {
|
||||
if (this.preloadedPages.has(url)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'text/html' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const html = await response.text();
|
||||
this.preloadCache.set(url, html);
|
||||
this.preloadedPages.add(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('호버 프리로드 실패:', url, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 느린 연결 감지
|
||||
*/
|
||||
isSlowConnection() {
|
||||
if ('connection' in navigator) {
|
||||
const connection = navigator.connection;
|
||||
return connection.effectiveType === 'slow-2g' ||
|
||||
connection.effectiveType === '2g' ||
|
||||
connection.saveData === true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 딜레이 유틸리티
|
||||
*/
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 서비스 워커 해제 및 캐시 정리
|
||||
*/
|
||||
async unregisterServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
for (const registration of registrations) {
|
||||
if (!registration.active || !registration.active.scriptURL.includes('push-sw.js')) {
|
||||
await registration.unregister();
|
||||
}
|
||||
}
|
||||
// 모든 캐시 삭제
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
} catch (error) {
|
||||
// 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리로드된 페이지 가져오기
|
||||
*/
|
||||
getPreloadedPage(url) {
|
||||
return this.preloadCache.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 정리
|
||||
*/
|
||||
clearCache() {
|
||||
this.preloadCache.clear();
|
||||
this.resourceCache.clear();
|
||||
this.preloadedPages.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스
|
||||
window.pagePreloader = new PagePreloader();
|
||||
259
system3-nonconformance/web/public/static/js/core/permissions.js
Normal file
259
system3-nonconformance/web/public/static/js/core/permissions.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 단순화된 페이지 권한 관리 시스템
|
||||
* admin/user 구조에서 페이지별 접근 권한을 관리
|
||||
*/
|
||||
|
||||
class PagePermissionManager {
|
||||
constructor() {
|
||||
this.currentUser = null;
|
||||
this.pagePermissions = new Map();
|
||||
this.defaultPages = this.initDefaultPages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 페이지 목록 초기화
|
||||
*/
|
||||
initDefaultPages() {
|
||||
return {
|
||||
'issues_dashboard': { title: '현황판', defaultAccess: true },
|
||||
'issues_manage': { title: '부적합 관리', defaultAccess: true },
|
||||
'issues_inbox': { title: '수신함', defaultAccess: true },
|
||||
'issues_management': { title: '관리함', defaultAccess: false },
|
||||
'issues_archive': { title: '폐기함', defaultAccess: false },
|
||||
'reports': { title: '보고서', defaultAccess: false },
|
||||
'ai_assistant': { title: 'AI 어시스턴트', defaultAccess: false }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 설정
|
||||
* @param {Object} user - 사용자 객체
|
||||
*/
|
||||
setUser(user) {
|
||||
this.currentUser = user;
|
||||
this.loadPagePermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자별 페이지 권한 로드
|
||||
*/
|
||||
/**
|
||||
* SSO 토큰 직접 읽기 (api.js 로딩 전에도 동작)
|
||||
*/
|
||||
_getToken() {
|
||||
// 1) window.TokenManager (api.js 로딩 완료 시)
|
||||
if (window.TokenManager) return window.TokenManager.getToken();
|
||||
// 2) SSO 쿠키 직접 읽기
|
||||
const match = document.cookie.match(/(?:^|; )sso_token=([^;]*)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
// 3) localStorage 폴백
|
||||
return localStorage.getItem('sso_token');
|
||||
}
|
||||
|
||||
async loadPagePermissions() {
|
||||
if (!this.currentUser) return;
|
||||
|
||||
const userId = this.currentUser.id || this.currentUser.user_id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
// API에서 사용자별 페이지 권한 가져오기
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const token = this._getToken();
|
||||
const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const pagePermissions = await response.json();
|
||||
this.pagePermissions.clear(); // 기존 권한 초기화
|
||||
pagePermissions.forEach(perm => {
|
||||
this.pagePermissions.set(perm.page_name, perm.can_access);
|
||||
});
|
||||
console.log('페이지 권한 로드 완료:', this.pagePermissions);
|
||||
} else {
|
||||
console.warn('페이지 권한 로드 실패, 기본 권한 사용');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('페이지 권한 로드 실패, 기본 권한 사용:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 접근 권한 체크
|
||||
* @param {string} pageName - 체크할 페이지명
|
||||
* @returns {boolean} 접근 권한 여부
|
||||
*/
|
||||
canAccessPage(pageName) {
|
||||
if (!this.currentUser) return false;
|
||||
|
||||
// admin은 모든 페이지 접근 가능
|
||||
if (this.currentUser.role === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 개별 페이지 권한이 설정되어 있으면 우선 적용
|
||||
if (this.pagePermissions.has(pageName)) {
|
||||
return this.pagePermissions.get(pageName);
|
||||
}
|
||||
|
||||
// 기본 권한 확인
|
||||
const pageConfig = this.defaultPages[pageName];
|
||||
return pageConfig ? pageConfig.defaultAccess : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 요소 페이지 권한 제어
|
||||
* @param {string} selector - CSS 선택자
|
||||
* @param {string} pageName - 필요한 페이지 권한
|
||||
* @param {string} action - 'show'|'hide'|'disable'|'enable'
|
||||
*/
|
||||
controlElement(selector, pageName, action = 'show') {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
const hasAccess = this.canAccessPage(pageName);
|
||||
|
||||
elements.forEach(element => {
|
||||
switch (action) {
|
||||
case 'show':
|
||||
element.style.display = hasAccess ? '' : 'none';
|
||||
break;
|
||||
case 'hide':
|
||||
element.style.display = hasAccess ? 'none' : '';
|
||||
break;
|
||||
case 'disable':
|
||||
element.disabled = !hasAccess;
|
||||
if (!hasAccess) {
|
||||
element.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
break;
|
||||
case 'enable':
|
||||
element.disabled = hasAccess;
|
||||
if (hasAccess) {
|
||||
element.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 구성 생성
|
||||
* @returns {Array} 페이지 권한에 따른 메뉴 구성
|
||||
*/
|
||||
getMenuConfig() {
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'issues_create',
|
||||
title: '부적합 등록',
|
||||
icon: 'fas fa-plus-circle',
|
||||
path: '#issues/create',
|
||||
pageName: 'issues_create'
|
||||
},
|
||||
{
|
||||
id: 'issues_view',
|
||||
title: '부적합 조회',
|
||||
icon: 'fas fa-search',
|
||||
path: '#issues/view',
|
||||
pageName: 'issues_view'
|
||||
},
|
||||
{
|
||||
id: 'issues_manage',
|
||||
title: '부적합 관리',
|
||||
icon: 'fas fa-tasks',
|
||||
path: '#issues/manage',
|
||||
pageName: 'issues_manage'
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
title: '보고서',
|
||||
icon: 'fas fa-chart-bar',
|
||||
path: '#reports',
|
||||
pageName: 'reports'
|
||||
}
|
||||
];
|
||||
|
||||
// 페이지 권한에 따라 메뉴 필터링
|
||||
return menuItems.filter(item => this.canAccessPage(item.pageName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 권한 부여
|
||||
* @param {number} userId - 사용자 ID
|
||||
* @param {string} pageName - 페이지명
|
||||
* @param {boolean} canAccess - 접근 허용 여부
|
||||
* @param {string} notes - 메모
|
||||
*/
|
||||
async grantPageAccess(userId, pageName, canAccess, notes = '') {
|
||||
if (this.currentUser.role !== 'admin') {
|
||||
throw new Error('관리자만 권한을 설정할 수 있습니다.');
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/page-permissions/grant`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this._getToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
page_name: pageName,
|
||||
can_access: canAccess,
|
||||
notes: notes
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('페이지 권한 설정 실패');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 설정 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 페이지 권한 목록 조회
|
||||
* @param {number} userId - 사용자 ID
|
||||
* @returns {Array} 페이지 권한 목록
|
||||
*/
|
||||
async getUserPagePermissions(userId) {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this._getToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('페이지 권한 목록 조회 실패');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 목록 조회 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 페이지 목록과 설명 가져오기
|
||||
* @returns {Object} 페이지 목록
|
||||
*/
|
||||
getAllPages() {
|
||||
return this.defaultPages;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 페이지 권한 관리자 인스턴스
|
||||
window.pagePermissionManager = new PagePermissionManager();
|
||||
|
||||
// 편의 함수들
|
||||
window.canAccessPage = (pageName) => window.pagePermissionManager.canAccessPage(pageName);
|
||||
window.controlElement = (selector, pageName, action) => window.pagePermissionManager.controlElement(selector, pageName, action);
|
||||
139
system3-nonconformance/web/public/static/js/date-utils.js
Normal file
139
system3-nonconformance/web/public/static/js/date-utils.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 날짜 관련 유틸리티 함수들
|
||||
* 한국 표준시(KST) 기준으로 처리
|
||||
*/
|
||||
|
||||
const DateUtils = {
|
||||
/**
|
||||
* UTC 시간을 KST로 변환
|
||||
* @param {string|Date} dateInput - UTC 날짜 문자열 또는 Date 객체
|
||||
* @returns {Date} KST 시간대의 Date 객체
|
||||
*/
|
||||
toKST(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
// UTC 시간에 9시간 추가 (KST = UTC+9)
|
||||
return new Date(date.getTime() + (date.getTimezoneOffset() * 60000) + (9 * 3600000));
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 KST 시간 가져오기
|
||||
* @returns {Date} 현재 KST 시간
|
||||
*/
|
||||
nowKST() {
|
||||
const now = new Date();
|
||||
return this.toKST(now);
|
||||
},
|
||||
|
||||
/**
|
||||
* KST 날짜를 한국식 문자열로 포맷
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @param {boolean} includeTime - 시간 포함 여부
|
||||
* @returns {string} 포맷된 날짜 문자열
|
||||
*/
|
||||
formatKST(dateInput, includeTime = false) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'Asia/Seoul'
|
||||
};
|
||||
|
||||
if (includeTime) {
|
||||
options.hour = '2-digit';
|
||||
options.minute = '2-digit';
|
||||
options.hour12 = false;
|
||||
}
|
||||
|
||||
return date.toLocaleString('ko-KR', options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 상대적 시간 표시 (예: 3분 전, 2시간 전)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {string} 상대적 시간 문자열
|
||||
*/
|
||||
getRelativeTime(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return '방금 전';
|
||||
if (diffMin < 60) return `${diffMin}분 전`;
|
||||
if (diffHour < 24) return `${diffHour}시간 전`;
|
||||
if (diffDay < 7) return `${diffDay}일 전`;
|
||||
|
||||
return this.formatKST(date);
|
||||
},
|
||||
|
||||
/**
|
||||
* 오늘 날짜인지 확인 (KST 기준)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isToday(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const today = new Date();
|
||||
|
||||
return date.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' }) ===
|
||||
today.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
},
|
||||
|
||||
/**
|
||||
* 이번 주인지 확인 (KST 기준, 월요일 시작)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isThisWeek(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const now = new Date();
|
||||
|
||||
// 주의 시작일 (월요일) 계산
|
||||
const startOfWeek = new Date(now);
|
||||
const day = startOfWeek.getDay();
|
||||
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
|
||||
startOfWeek.setDate(diff);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
// 주의 끝일 (일요일) 계산
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
return date >= startOfWeek && date <= endOfWeek;
|
||||
},
|
||||
|
||||
/**
|
||||
* ISO 문자열을 로컬 date input 값으로 변환
|
||||
* @param {string} isoString - ISO 날짜 문자열
|
||||
* @returns {string} YYYY-MM-DD 형식
|
||||
*/
|
||||
toDateInputValue(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 날짜 차이 계산 (일 단위)
|
||||
* @param {string|Date} date1 - 첫 번째 날짜
|
||||
* @param {string|Date} date2 - 두 번째 날짜
|
||||
* @returns {number} 일 수 차이
|
||||
*/
|
||||
getDaysDiff(date1, date2) {
|
||||
const d1 = typeof date1 === 'string' ? new Date(date1) : date1;
|
||||
const d2 = typeof date2 === 'string' ? new Date(date2) : date2;
|
||||
const diffMs = Math.abs(d2 - d1);
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
};
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.DateUtils = DateUtils;
|
||||
134
system3-nonconformance/web/public/static/js/image-utils.js
Normal file
134
system3-nonconformance/web/public/static/js/image-utils.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 이미지 압축 및 최적화 유틸리티
|
||||
*/
|
||||
|
||||
const ImageUtils = {
|
||||
/**
|
||||
* 이미지를 압축하고 리사이즈
|
||||
* @param {File|Blob|String} source - 이미지 파일, Blob 또는 base64 문자열
|
||||
* @param {Object} options - 압축 옵션
|
||||
* @returns {Promise<String>} - 압축된 base64 이미지
|
||||
*/
|
||||
async compressImage(source, options = {}) {
|
||||
const {
|
||||
maxWidth = 1024, // 최대 너비
|
||||
maxHeight = 1024, // 최대 높이
|
||||
quality = 0.7, // JPEG 품질 (0-1)
|
||||
format = 'jpeg' // 출력 형식
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let img = new Image();
|
||||
|
||||
// 이미지 로드 완료 시
|
||||
img.onload = () => {
|
||||
// Canvas 생성
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 리사이즈 계산
|
||||
let { width, height } = this.calculateDimensions(
|
||||
img.width,
|
||||
img.height,
|
||||
maxWidth,
|
||||
maxHeight
|
||||
);
|
||||
|
||||
// Canvas 크기 설정
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// 압축된 이미지를 base64로 변환
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('이미지 압축 실패'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
}, `image/${format}`, quality);
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error('이미지 로드 실패'));
|
||||
|
||||
// 소스 타입에 따라 처리
|
||||
if (typeof source === 'string') {
|
||||
// Base64 문자열인 경우
|
||||
img.src = source;
|
||||
} else if (source instanceof File || source instanceof Blob) {
|
||||
// File 또는 Blob인 경우
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
img.src = reader.result;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(source);
|
||||
} else {
|
||||
reject(new Error('지원하지 않는 이미지 형식'));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 크기 계산 (비율 유지)
|
||||
*/
|
||||
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
|
||||
// 원본 크기가 제한 내에 있으면 그대로 반환
|
||||
if (originalWidth <= maxWidth && originalHeight <= maxHeight) {
|
||||
return { width: originalWidth, height: originalHeight };
|
||||
}
|
||||
|
||||
// 비율 계산
|
||||
const widthRatio = maxWidth / originalWidth;
|
||||
const heightRatio = maxHeight / originalHeight;
|
||||
const ratio = Math.min(widthRatio, heightRatio);
|
||||
|
||||
return {
|
||||
width: Math.round(originalWidth * ratio),
|
||||
height: Math.round(originalHeight * ratio)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 파일 크기를 사람이 읽을 수 있는 형식으로 변환
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
/**
|
||||
* Base64 문자열의 크기 계산
|
||||
*/
|
||||
getBase64Size(base64String) {
|
||||
const base64Length = base64String.length - (base64String.indexOf(',') + 1);
|
||||
const padding = (base64String.charAt(base64String.length - 2) === '=') ? 2 :
|
||||
((base64String.charAt(base64String.length - 1) === '=') ? 1 : 0);
|
||||
return (base64Length * 0.75) - padding;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 미리보기 생성 (썸네일)
|
||||
*/
|
||||
async createThumbnail(source, size = 150) {
|
||||
return this.compressImage(source, {
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
quality: 0.8
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.ImageUtils = ImageUtils;
|
||||
3
system3-nonconformance/web/public/static/js/lib/purify.min.js
vendored
Normal file
3
system3-nonconformance/web/public/static/js/lib/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
219
system3-nonconformance/web/public/static/js/m/m-common.js
Normal file
219
system3-nonconformance/web/public/static/js/m/m-common.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* m-common.js — TKQC 모바일 공통 JS
|
||||
* 바텀 네비게이션, 바텀시트 엔진, 인증, 뷰포트 가드, 토스트
|
||||
*/
|
||||
|
||||
/* ===== Viewport Guard: 데스크탑이면 리다이렉트 ===== */
|
||||
(function () {
|
||||
if (window.innerWidth > 768) {
|
||||
var page = location.pathname.replace('/m/', '').replace('.html', '');
|
||||
var map = { dashboard: '/issues-dashboard.html', inbox: '/issues-inbox.html', management: '/issues-management.html' };
|
||||
window.location.replace(map[page] || '/issues-dashboard.html');
|
||||
}
|
||||
})();
|
||||
|
||||
/* ===== KST Date Utilities ===== */
|
||||
// DB에 KST로 저장된 naive datetime을 그대로 표시 (이중 변환 방지)
|
||||
function getKSTDate(date) {
|
||||
return new Date(date);
|
||||
}
|
||||
function formatKSTDate(date) {
|
||||
return new Date(date).toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
}
|
||||
function formatKSTTime(date) {
|
||||
return new Date(date).toLocaleTimeString('ko-KR', { timeZone: 'Asia/Seoul', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function formatKSTDateTime(date) {
|
||||
return new Date(date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function getKSTToday() {
|
||||
var kst = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
|
||||
return new Date(kst.getFullYear(), kst.getMonth(), kst.getDate());
|
||||
}
|
||||
function getTimeAgo(date) {
|
||||
var now = new Date();
|
||||
var d = new Date(date);
|
||||
var diff = now - d;
|
||||
var mins = Math.floor(diff / 60000);
|
||||
var hours = Math.floor(diff / 3600000);
|
||||
var days = Math.floor(diff / 86400000);
|
||||
if (mins < 1) return '방금 전';
|
||||
if (mins < 60) return mins + '분 전';
|
||||
if (hours < 24) return hours + '시간 전';
|
||||
if (days < 7) return days + '일 전';
|
||||
return formatKSTDate(date);
|
||||
}
|
||||
|
||||
/* ===== Bottom Navigation ===== */
|
||||
function renderBottomNav(activePage) {
|
||||
var nav = document.createElement('nav');
|
||||
nav.className = 'm-bottom-nav';
|
||||
var items = [
|
||||
{ icon: 'fa-chart-line', label: '현황판', href: '/m/dashboard.html', page: 'dashboard' },
|
||||
{ icon: 'fa-inbox', label: '수신함', href: '/m/inbox.html', page: 'inbox' },
|
||||
{ icon: 'fa-tasks', label: '관리함', href: '/m/management.html', page: 'management' },
|
||||
{ icon: 'fa-bullhorn', label: '신고', href: (location.hostname.includes('technicalkorea.net') ? 'https://tkreport.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30180'), page: 'report', external: true, highlight: true }
|
||||
];
|
||||
items.forEach(function (item) {
|
||||
var a = document.createElement('a');
|
||||
a.href = item.href;
|
||||
a.className = 'm-nav-item';
|
||||
if (item.page === activePage) a.classList.add('active');
|
||||
if (item.highlight) a.classList.add('highlight');
|
||||
if (item.external) { a.target = '_blank'; a.rel = 'noopener'; }
|
||||
a.innerHTML = '<i class="fas ' + item.icon + '"></i><span>' + item.label + '</span>';
|
||||
nav.appendChild(a);
|
||||
});
|
||||
document.body.appendChild(nav);
|
||||
}
|
||||
|
||||
/* ===== Bottom Sheet Engine ===== */
|
||||
var _activeSheets = [];
|
||||
|
||||
function openSheet(sheetId) {
|
||||
var overlay = document.getElementById(sheetId + 'Overlay');
|
||||
var sheet = document.getElementById(sheetId + 'Sheet');
|
||||
if (!overlay || !sheet) return;
|
||||
overlay.classList.add('open');
|
||||
sheet.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
_activeSheets.push(sheetId);
|
||||
}
|
||||
|
||||
function closeSheet(sheetId) {
|
||||
var overlay = document.getElementById(sheetId + 'Overlay');
|
||||
var sheet = document.getElementById(sheetId + 'Sheet');
|
||||
if (!overlay || !sheet) return;
|
||||
overlay.classList.remove('open');
|
||||
sheet.classList.remove('open');
|
||||
_activeSheets = _activeSheets.filter(function (id) { return id !== sheetId; });
|
||||
if (_activeSheets.length === 0) document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function closeAllSheets() {
|
||||
_activeSheets.slice().forEach(function (id) { closeSheet(id); });
|
||||
}
|
||||
|
||||
// ESC key closes topmost sheet
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && _activeSheets.length) {
|
||||
closeSheet(_activeSheets[_activeSheets.length - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
/* ===== Toast ===== */
|
||||
var _toastTimer = null;
|
||||
function showToast(message, type, duration) {
|
||||
type = type || 'info';
|
||||
duration = duration || 3000;
|
||||
var existing = document.querySelector('.m-toast');
|
||||
if (existing) existing.remove();
|
||||
clearTimeout(_toastTimer);
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'm-toast';
|
||||
if (type !== 'info') toast.classList.add(type);
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(function () { toast.classList.add('show'); });
|
||||
_toastTimer = setTimeout(function () {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(function () { toast.remove(); }, 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/* ===== Photo Modal ===== */
|
||||
function openPhotoModal(src) {
|
||||
var modal = document.getElementById('photoModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'photoModal';
|
||||
modal.className = 'm-photo-modal';
|
||||
modal.innerHTML = '<button class="m-photo-modal-close" onclick="closePhotoModal()"><i class="fas fa-times"></i></button><img>';
|
||||
modal.addEventListener('click', function (e) { if (e.target === modal) closePhotoModal(); });
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
modal.querySelector('img').src = src;
|
||||
modal.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closePhotoModal() {
|
||||
var modal = document.getElementById('photoModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('open');
|
||||
if (!_activeSheets.length) document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Auth Helper (authManager 위임 + 루프 방지) ===== */
|
||||
var _mRedirectKey = '_sso_redirect_ts';
|
||||
var _mRedirectCooldown = 5000; // 5초 내 재리다이렉트 방지
|
||||
|
||||
function _mSafeRedirectToLogin() {
|
||||
var last = parseInt(sessionStorage.getItem(_mRedirectKey) || '0', 10);
|
||||
if (Date.now() - last < _mRedirectCooldown) {
|
||||
console.warn('[TKQC-M] 리다이렉트 루프 감지 — 로그인 페이지로 이동하지 않음');
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(_mRedirectKey, String(Date.now()));
|
||||
window.location.href = _getLoginUrl();
|
||||
}
|
||||
|
||||
async function mCheckAuth() {
|
||||
// authManager가 있으면 위임 (SW 정리 + 캐시 관리 포함)
|
||||
if (window.authManager && typeof window.authManager.checkAuth === 'function') {
|
||||
var user = await window.authManager.checkAuth();
|
||||
if (user) {
|
||||
sessionStorage.removeItem(_mRedirectKey);
|
||||
return user;
|
||||
}
|
||||
_mSafeRedirectToLogin();
|
||||
return null;
|
||||
}
|
||||
// 폴백: authManager 없는 경우
|
||||
var token = TokenManager.getToken();
|
||||
if (!token) {
|
||||
_mSafeRedirectToLogin();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
var user = await AuthAPI.getCurrentUser();
|
||||
sessionStorage.removeItem(_mRedirectKey);
|
||||
return user;
|
||||
} catch (e) {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
_mSafeRedirectToLogin();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Loading Overlay ===== */
|
||||
function hideLoading() {
|
||||
var el = document.getElementById('loadingOverlay');
|
||||
if (el) { el.classList.add('hide'); setTimeout(function () { el.remove(); }, 300); }
|
||||
}
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function getPhotoPaths(issue) {
|
||||
return [issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean);
|
||||
}
|
||||
|
||||
function getCompletionPhotoPaths(issue) {
|
||||
return [issue.completion_photo_path, issue.completion_photo_path2, issue.completion_photo_path3, issue.completion_photo_path4, issue.completion_photo_path5].filter(Boolean);
|
||||
}
|
||||
|
||||
function renderPhotoThumbs(photos) {
|
||||
if (!photos || !photos.length) return '';
|
||||
return '<div class="m-photo-row">' + photos.map(function (p, i) {
|
||||
return '<img src="' + p + '" class="m-photo-thumb" onclick="openPhotoModal(\'' + p + '\')" alt="사진 ' + (i + 1) + '">';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
757
system3-nonconformance/web/public/static/js/m/m-dashboard.js
Normal file
757
system3-nonconformance/web/public/static/js/m/m-dashboard.js
Normal file
@@ -0,0 +1,757 @@
|
||||
/**
|
||||
* m-dashboard.js — 현황판 모바일 페이지 로직
|
||||
*/
|
||||
|
||||
var currentUser = null;
|
||||
var allIssues = [];
|
||||
var projects = [];
|
||||
var filteredIssues = [];
|
||||
|
||||
// 모달/시트 상태
|
||||
var selectedOpinionIssueId = null;
|
||||
var selectedCommentIssueId = null;
|
||||
var selectedCommentOpinionIndex = null;
|
||||
var selectedReplyIssueId = null;
|
||||
var selectedReplyOpinionIndex = null;
|
||||
var selectedReplyCommentIndex = null;
|
||||
var selectedCompletionIssueId = null;
|
||||
var selectedRejectionIssueId = null;
|
||||
var completionPhotoBase64 = null;
|
||||
|
||||
// 수정 상태
|
||||
var editMode = null; // 'opinion', 'comment', 'reply'
|
||||
var editIssueId = null;
|
||||
var editOpinionIndex = null;
|
||||
var editCommentIndex = null;
|
||||
var editReplyIndex = null;
|
||||
|
||||
// ===== 초기화 =====
|
||||
async function initialize() {
|
||||
currentUser = await mCheckAuth();
|
||||
if (!currentUser) return;
|
||||
|
||||
await Promise.all([loadProjects(), loadIssues()]);
|
||||
updateStatistics();
|
||||
renderIssues();
|
||||
renderBottomNav('dashboard');
|
||||
hideLoading();
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/projects/', {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
});
|
||||
if (resp.ok) {
|
||||
projects = await resp.json();
|
||||
var sel = document.getElementById('projectFilter');
|
||||
sel.innerHTML = '<option value="">전체 프로젝트</option>';
|
||||
projects.forEach(function (p) {
|
||||
sel.innerHTML += '<option value="' + p.id + '">' + escapeHtml(p.project_name) + '</option>';
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('프로젝트 로드 실패:', e); }
|
||||
}
|
||||
|
||||
async function loadIssues() {
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/issues/admin/all', {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
});
|
||||
if (resp.ok) {
|
||||
var data = await resp.json();
|
||||
allIssues = data.filter(function (i) { return i.review_status === 'in_progress'; });
|
||||
|
||||
// 프로젝트별 순번 재할당
|
||||
allIssues.sort(function (a, b) { return new Date(a.reviewed_at) - new Date(b.reviewed_at); });
|
||||
var groups = {};
|
||||
allIssues.forEach(function (issue) {
|
||||
if (!groups[issue.project_id]) groups[issue.project_id] = [];
|
||||
groups[issue.project_id].push(issue);
|
||||
});
|
||||
Object.keys(groups).forEach(function (pid) {
|
||||
groups[pid].forEach(function (issue, idx) { issue.project_sequence_no = idx + 1; });
|
||||
});
|
||||
|
||||
filteredIssues = allIssues.slice();
|
||||
}
|
||||
} catch (e) { console.error('이슈 로드 실패:', e); }
|
||||
}
|
||||
|
||||
function updateStatistics() {
|
||||
var today = new Date().toDateString();
|
||||
var todayIssues = allIssues.filter(function (i) { return i.reviewed_at && new Date(i.reviewed_at).toDateString() === today; });
|
||||
var pending = allIssues.filter(function (i) { return i.completion_requested_at && i.review_status === 'in_progress'; });
|
||||
var overdue = allIssues.filter(function (i) { return i.expected_completion_date && new Date(i.expected_completion_date) < new Date(); });
|
||||
|
||||
document.getElementById('totalInProgress').textContent = allIssues.length;
|
||||
document.getElementById('todayNew').textContent = todayIssues.length;
|
||||
document.getElementById('pendingCompletion').textContent = pending.length;
|
||||
document.getElementById('overdue').textContent = overdue.length;
|
||||
}
|
||||
|
||||
function filterByProject() {
|
||||
var pid = document.getElementById('projectFilter').value;
|
||||
filteredIssues = pid ? allIssues.filter(function (i) { return i.project_id == pid; }) : allIssues.slice();
|
||||
renderIssues();
|
||||
}
|
||||
|
||||
// ===== 이슈 상태 판별 =====
|
||||
function getIssueStatus(issue) {
|
||||
if (issue.review_status === 'completed') return 'completed';
|
||||
if (issue.completion_requested_at) return 'pending_completion';
|
||||
if (issue.expected_completion_date) {
|
||||
var diff = (new Date(issue.expected_completion_date) - new Date()) / 86400000;
|
||||
if (diff < 0) return 'overdue';
|
||||
if (diff <= 3) return 'urgent';
|
||||
}
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
function getStatusBadgeHtml(status) {
|
||||
var map = {
|
||||
'in_progress': '<span class="m-badge in-progress"><i class="fas fa-cog"></i> 진행 중</span>',
|
||||
'urgent': '<span class="m-badge urgent"><i class="fas fa-exclamation-triangle"></i> 긴급</span>',
|
||||
'overdue': '<span class="m-badge overdue"><i class="fas fa-clock"></i> 지연됨</span>',
|
||||
'pending_completion': '<span class="m-badge pending-completion"><i class="fas fa-hourglass-half"></i> 완료 대기</span>',
|
||||
'completed': '<span class="m-badge completed"><i class="fas fa-check-circle"></i> 완료됨</span>'
|
||||
};
|
||||
return map[status] || map['in_progress'];
|
||||
}
|
||||
|
||||
// ===== 렌더링 =====
|
||||
function renderIssues() {
|
||||
var container = document.getElementById('issuesList');
|
||||
var empty = document.getElementById('emptyState');
|
||||
|
||||
if (!filteredIssues.length) {
|
||||
container.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
|
||||
// 날짜별 그룹화 (reviewed_at 기준)
|
||||
var grouped = {};
|
||||
var dateObjs = {};
|
||||
filteredIssues.forEach(function (issue) {
|
||||
var d = new Date(issue.reviewed_at || issue.report_date);
|
||||
var key = d.toLocaleDateString('ko-KR');
|
||||
if (!grouped[key]) { grouped[key] = []; dateObjs[key] = d; }
|
||||
grouped[key].push(issue);
|
||||
});
|
||||
|
||||
var html = Object.keys(grouped)
|
||||
.sort(function (a, b) { return dateObjs[b] - dateObjs[a]; })
|
||||
.map(function (dateKey) {
|
||||
var issues = grouped[dateKey];
|
||||
return '<div class="m-date-group"><div class="m-date-header">' +
|
||||
'<i class="fas fa-calendar-alt"></i>' +
|
||||
'<span>' + dateKey + '</span>' +
|
||||
'<span class="m-date-count">(' + issues.length + '건)</span></div>' +
|
||||
issues.map(function (issue) { return renderIssueCard(issue); }).join('') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderIssueCard(issue) {
|
||||
var project = projects.find(function (p) { return p.id === issue.project_id; });
|
||||
var projectName = project ? project.project_name : '미지정';
|
||||
var status = getIssueStatus(issue);
|
||||
var photos = getPhotoPaths(issue);
|
||||
var isPending = status === 'pending_completion';
|
||||
|
||||
// 의견/댓글 파싱
|
||||
var opinionsHtml = renderOpinions(issue);
|
||||
|
||||
// 완료 반려 내용
|
||||
var rejectionHtml = '';
|
||||
if (issue.completion_rejection_reason) {
|
||||
var rejAt = issue.completion_rejected_at ? formatKSTDateTime(issue.completion_rejected_at) : '';
|
||||
rejectionHtml = '<div style="background:#fff7ed;border-left:3px solid #f97316;border-radius:8px;padding:8px 10px;margin-top:8px;font-size:12px;">' +
|
||||
'<div style="font-weight:600;color:#c2410c;margin-bottom:2px"><i class="fas fa-exclamation-triangle" style="margin-right:4px"></i>완료 반려</div>' +
|
||||
'<div style="color:#9a3412">' + (rejAt ? '[' + rejAt + '] ' : '') + escapeHtml(issue.completion_rejection_reason) + '</div></div>';
|
||||
}
|
||||
|
||||
// 완료 대기 정보
|
||||
var completionInfoHtml = '';
|
||||
if (isPending) {
|
||||
var cPhotos = getCompletionPhotoPaths(issue);
|
||||
completionInfoHtml = '<div class="m-completion-info">' +
|
||||
'<div style="font-size:12px;font-weight:600;color:#6d28d9;margin-bottom:6px"><i class="fas fa-check-circle" style="margin-right:4px"></i>완료 신청 정보</div>' +
|
||||
(cPhotos.length ? renderPhotoThumbs(cPhotos) : '<div style="font-size:12px;color:#9ca3af">완료 사진 없음</div>') +
|
||||
'<div style="font-size:12px;color:#6b7280;margin-top:4px">' + (issue.completion_comment || '코멘트 없음') + '</div>' +
|
||||
'<div style="font-size:11px;color:#9ca3af;margin-top:4px">신청: ' + formatKSTDateTime(issue.completion_requested_at) + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 액션 버튼
|
||||
var actionHtml = '';
|
||||
if (isPending) {
|
||||
actionHtml = '<div class="m-action-row">' +
|
||||
'<button class="m-action-btn red" onclick="openRejectionSheet(' + issue.id + ')"><i class="fas fa-times"></i>반려</button>' +
|
||||
'<button class="m-action-btn green" onclick="event.stopPropagation();openOpinionSheet(' + issue.id + ')"><i class="fas fa-comment-medical"></i>의견</button>' +
|
||||
'</div>';
|
||||
} else {
|
||||
actionHtml = '<div class="m-action-row">' +
|
||||
'<button class="m-action-btn green" onclick="openCompletionSheet(' + issue.id + ')"><i class="fas fa-check"></i>완료신청</button>' +
|
||||
'<button class="m-action-btn blue" onclick="event.stopPropagation();openOpinionSheet(' + issue.id + ')"><i class="fas fa-comment-medical"></i>의견</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return '<div class="m-card border-blue">' +
|
||||
'<div class="m-card-header">' +
|
||||
'<div><span class="m-card-no">No.' + (issue.project_sequence_no || '-') + '</span>' +
|
||||
'<span class="m-card-project">' + escapeHtml(projectName) + '</span></div>' +
|
||||
getStatusBadgeHtml(status) +
|
||||
'</div>' +
|
||||
'<div class="m-card-title">' + escapeHtml(getIssueTitle(issue)) + '</div>' +
|
||||
'<div class="m-card-body">' +
|
||||
// 상세 내용
|
||||
'<div style="font-size:13px;color:#6b7280;line-height:1.5;margin-bottom:8px" class="text-ellipsis-3">' + escapeHtml(getIssueDetail(issue)) + '</div>' +
|
||||
// 정보행
|
||||
'<div style="display:flex;gap:12px;font-size:12px;color:#9ca3af;margin-bottom:8px">' +
|
||||
'<span><i class="fas fa-user" style="margin-right:3px"></i>' + escapeHtml(issue.reporter?.full_name || issue.reporter?.username || '-') + '</span>' +
|
||||
'<span><i class="fas fa-tag" style="margin-right:3px"></i>' + getCategoryText(issue.category || issue.final_category) + '</span>' +
|
||||
(issue.expected_completion_date ? '<span><i class="fas fa-calendar-alt" style="margin-right:3px"></i>' + formatKSTDate(issue.expected_completion_date) + '</span>' : '') +
|
||||
'</div>' +
|
||||
// 사진
|
||||
(photos.length ? renderPhotoThumbs(photos) : '') +
|
||||
// 해결방안 / 의견 섹션
|
||||
'<div style="margin-top:8px">' +
|
||||
renderManagementComment(issue) +
|
||||
opinionsHtml +
|
||||
'</div>' +
|
||||
rejectionHtml +
|
||||
completionInfoHtml +
|
||||
'</div>' +
|
||||
actionHtml +
|
||||
'<div class="m-card-footer">' +
|
||||
'<span>' + getTimeAgo(issue.report_date) + '</span>' +
|
||||
'<span>ID: ' + issue.id + '</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ===== 관리 코멘트 (확정 해결방안) =====
|
||||
function renderManagementComment(issue) {
|
||||
var raw = issue.management_comment || issue.final_description || '';
|
||||
raw = raw.replace(/\[완료 반려[^\]]*\][^\n]*/g, '').trim();
|
||||
var defaults = ['중복작업 신고용', '상세 내용 없음', '자재 누락', '설계 오류', '반입 불량', '검사 누락', '기타', '부적합명', '상세내용', '상세 내용'];
|
||||
var lines = raw.split('\n').filter(function (l) { var t = l.trim(); return t && defaults.indexOf(t) < 0; });
|
||||
var content = lines.join('\n').trim();
|
||||
|
||||
return '<div style="background:#fef2f2;border-left:3px solid #fca5a5;border-radius:8px;padding:8px 10px;margin-bottom:6px">' +
|
||||
'<div style="font-size:' + (content ? '12' : '12') + 'px;color:' + (content ? '#991b1b' : '#d1d5db') + ';line-height:1.5;white-space:pre-wrap">' +
|
||||
(content ? escapeHtml(content) : '확정된 해결 방안 없음') +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
// ===== 의견/댓글/답글 렌더링 =====
|
||||
function renderOpinions(issue) {
|
||||
if (!issue.solution || !issue.solution.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var opinions = issue.solution.split(/─{30,}/);
|
||||
var validOpinions = opinions.filter(function (o) { return o.trim(); });
|
||||
if (!validOpinions.length) return '';
|
||||
|
||||
var toggleId = 'opinions-' + issue.id;
|
||||
var html = '<button class="m-opinions-toggle" onclick="toggleOpinions(\'' + toggleId + '\')">' +
|
||||
'<i class="fas fa-comments"></i> 의견 ' + validOpinions.length + '개' +
|
||||
'<i class="fas fa-chevron-down" style="font-size:10px;transition:transform 0.2s" id="chevron-' + toggleId + '"></i></button>' +
|
||||
'<div id="' + toggleId + '" class="hidden" style="margin-top:6px">';
|
||||
|
||||
validOpinions.forEach(function (opinion, opinionIndex) {
|
||||
var trimmed = opinion.trim();
|
||||
var headerMatch = trimmed.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
|
||||
|
||||
if (headerMatch) {
|
||||
var author = headerMatch[1];
|
||||
var datetime = headerMatch[2];
|
||||
var rest = trimmed.substring(headerMatch[0].length).trim().split('\n');
|
||||
var mainContent = '';
|
||||
var comments = [];
|
||||
var currentCommentIdx = -1;
|
||||
|
||||
rest.forEach(function (line) {
|
||||
if (line.match(/^\s*└/)) {
|
||||
var cm = line.match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
||||
if (cm) { comments.push({ author: cm[1], datetime: cm[2], content: cm[3], replies: [] }); currentCommentIdx = comments.length - 1; }
|
||||
} else if (line.match(/^\s*↳/)) {
|
||||
var rm = line.match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
||||
if (rm && currentCommentIdx >= 0) { comments[currentCommentIdx].replies.push({ author: rm[1], datetime: rm[2], content: rm[3] }); }
|
||||
} else {
|
||||
mainContent += (mainContent ? '\n' : '') + line;
|
||||
}
|
||||
});
|
||||
|
||||
var isOwn = currentUser && (author === currentUser.full_name || author === currentUser.username);
|
||||
|
||||
html += '<div class="m-opinion-card">' +
|
||||
'<div class="m-opinion-header">' +
|
||||
'<div class="m-opinion-avatar">' + author.charAt(0) + '</div>' +
|
||||
'<span class="m-opinion-author">' + escapeHtml(author) + '</span>' +
|
||||
'<span class="m-opinion-time">' + escapeHtml(datetime) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="m-opinion-text">' + escapeHtml(mainContent) + '</div>' +
|
||||
'<div class="m-opinion-actions">' +
|
||||
'<button class="m-opinion-action-btn comment-btn" onclick="openCommentSheet(' + issue.id + ',' + opinionIndex + ')"><i class="fas fa-comment"></i>댓글</button>' +
|
||||
(isOwn ? '<button class="m-opinion-action-btn edit-btn" onclick="openEditSheet(\'opinion\',' + issue.id + ',' + opinionIndex + ')"><i class="fas fa-edit"></i>수정</button>' +
|
||||
'<button class="m-opinion-action-btn delete-btn" onclick="deleteOpinion(' + issue.id + ',' + opinionIndex + ')"><i class="fas fa-trash"></i>삭제</button>' : '') +
|
||||
'</div>';
|
||||
|
||||
// 댓글
|
||||
comments.forEach(function (comment, commentIndex) {
|
||||
var isOwnComment = currentUser && (comment.author === currentUser.full_name || comment.author === currentUser.username);
|
||||
html += '<div class="m-comment">' +
|
||||
'<div class="m-comment-header">' +
|
||||
'<div class="m-comment-avatar">' + comment.author.charAt(0) + '</div>' +
|
||||
'<span style="font-weight:600;color:#111827;font-size:11px">' + escapeHtml(comment.author) + '</span>' +
|
||||
'<span style="color:#9ca3af;font-size:10px">' + escapeHtml(comment.datetime) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="m-comment-text">' + escapeHtml(comment.content) + '</div>' +
|
||||
'<div style="display:flex;gap:4px;margin-top:4px;padding-left:22px">' +
|
||||
'<button class="m-opinion-action-btn reply-btn" onclick="openReplySheet(' + issue.id + ',' + opinionIndex + ',' + commentIndex + ')"><i class="fas fa-reply"></i>답글</button>' +
|
||||
(isOwnComment ? '<button class="m-opinion-action-btn edit-btn" onclick="openEditSheet(\'comment\',' + issue.id + ',' + opinionIndex + ',' + commentIndex + ')"><i class="fas fa-edit"></i></button>' +
|
||||
'<button class="m-opinion-action-btn delete-btn" onclick="deleteComment(' + issue.id + ',' + opinionIndex + ',' + commentIndex + ')"><i class="fas fa-trash"></i></button>' : '') +
|
||||
'</div>';
|
||||
|
||||
// 답글
|
||||
comment.replies.forEach(function (reply, replyIndex) {
|
||||
var isOwnReply = currentUser && (reply.author === currentUser.full_name || reply.author === currentUser.username);
|
||||
html += '<div class="m-reply">' +
|
||||
'<div class="m-reply-header">' +
|
||||
'<i class="fas fa-reply" style="color:#93c5fd;font-size:9px;margin-right:3px"></i>' +
|
||||
'<span style="font-weight:600;color:#111827;font-size:10px">' + escapeHtml(reply.author) + '</span>' +
|
||||
'<span style="color:#9ca3af;font-size:9px;margin-left:3px">' + escapeHtml(reply.datetime) + '</span>' +
|
||||
(isOwnReply ? '<button class="m-opinion-action-btn edit-btn" style="margin-left:auto;padding:1px 4px" onclick="openEditSheet(\'reply\',' + issue.id + ',' + opinionIndex + ',' + commentIndex + ',' + replyIndex + ')"><i class="fas fa-edit" style="font-size:9px"></i></button>' +
|
||||
'<button class="m-opinion-action-btn delete-btn" style="padding:1px 4px" onclick="deleteReply(' + issue.id + ',' + opinionIndex + ',' + commentIndex + ',' + replyIndex + ')"><i class="fas fa-trash" style="font-size:9px"></i></button>' : '') +
|
||||
'</div>' +
|
||||
'<div class="m-reply-text">' + escapeHtml(reply.content) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
html += '</div>'; // m-comment
|
||||
});
|
||||
|
||||
html += '</div>'; // m-opinion-card
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function toggleOpinions(id) {
|
||||
var el = document.getElementById(id);
|
||||
var chevron = document.getElementById('chevron-' + id);
|
||||
if (el.classList.contains('hidden')) {
|
||||
el.classList.remove('hidden');
|
||||
if (chevron) chevron.style.transform = 'rotate(180deg)';
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
if (chevron) chevron.style.transform = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 의견 제시 =====
|
||||
function openOpinionSheet(issueId) {
|
||||
selectedOpinionIssueId = issueId;
|
||||
document.getElementById('opinionText').value = '';
|
||||
openSheet('opinion');
|
||||
}
|
||||
|
||||
async function submitOpinion() {
|
||||
if (!selectedOpinionIssueId) return;
|
||||
var text = document.getElementById('opinionText').value.trim();
|
||||
if (!text) { showToast('의견을 입력해주세요.', 'warning'); return; }
|
||||
|
||||
try {
|
||||
var issueResp = await fetch(API_BASE_URL + '/issues/' + selectedOpinionIssueId, {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
});
|
||||
if (!issueResp.ok) throw new Error('이슈 조회 실패');
|
||||
var issue = await issueResp.json();
|
||||
|
||||
var now = new Date();
|
||||
var dateStr = now.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
var newOpinion = '[' + (currentUser.full_name || currentUser.username) + '] (' + dateStr + ')\n' + text;
|
||||
|
||||
var solution = issue.solution ? newOpinion + '\n' + '─'.repeat(50) + '\n' + issue.solution : newOpinion;
|
||||
|
||||
var resp = await fetch(API_BASE_URL + '/issues/' + selectedOpinionIssueId + '/management', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ solution: solution })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('의견이 추가되었습니다.', 'success');
|
||||
closeSheet('opinion');
|
||||
await refreshData();
|
||||
} else { throw new Error('저장 실패'); }
|
||||
} catch (e) {
|
||||
console.error('의견 제시 오류:', e);
|
||||
showToast('오류: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 댓글 추가 =====
|
||||
function openCommentSheet(issueId, opinionIndex) {
|
||||
selectedCommentIssueId = issueId;
|
||||
selectedCommentOpinionIndex = opinionIndex;
|
||||
document.getElementById('commentText').value = '';
|
||||
openSheet('comment');
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
if (!selectedCommentIssueId || selectedCommentOpinionIndex === null) return;
|
||||
var text = document.getElementById('commentText').value.trim();
|
||||
if (!text) { showToast('댓글을 입력해주세요.', 'warning'); return; }
|
||||
|
||||
try {
|
||||
var issue = await (await fetch(API_BASE_URL + '/issues/' + selectedCommentIssueId, {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
})).json();
|
||||
|
||||
var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
||||
if (selectedCommentOpinionIndex >= opinions.length) throw new Error('잘못된 인덱스');
|
||||
|
||||
var dateStr = new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
opinions[selectedCommentOpinionIndex] = opinions[selectedCommentOpinionIndex].trim() + '\n └ [' + (currentUser.full_name || currentUser.username) + '] (' + dateStr + '): ' + text;
|
||||
|
||||
var resp = await fetch(API_BASE_URL + '/issues/' + selectedCommentIssueId + '/management', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
|
||||
});
|
||||
|
||||
if (resp.ok) { showToast('댓글이 추가되었습니다.', 'success'); closeSheet('comment'); await refreshData(); }
|
||||
else throw new Error('저장 실패');
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 답글 추가 =====
|
||||
function openReplySheet(issueId, opinionIndex, commentIndex) {
|
||||
selectedReplyIssueId = issueId;
|
||||
selectedReplyOpinionIndex = opinionIndex;
|
||||
selectedReplyCommentIndex = commentIndex;
|
||||
document.getElementById('replyText').value = '';
|
||||
openSheet('reply');
|
||||
}
|
||||
|
||||
async function submitReply() {
|
||||
if (!selectedReplyIssueId || selectedReplyOpinionIndex === null || selectedReplyCommentIndex === null) return;
|
||||
var text = document.getElementById('replyText').value.trim();
|
||||
if (!text) { showToast('답글을 입력해주세요.', 'warning'); return; }
|
||||
|
||||
try {
|
||||
var issue = await (await fetch(API_BASE_URL + '/issues/' + selectedReplyIssueId, {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
})).json();
|
||||
|
||||
var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
||||
var lines = opinions[selectedReplyOpinionIndex].trim().split('\n');
|
||||
var dateStr = new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
var newReply = ' ↳ [' + (currentUser.full_name || currentUser.username) + '] (' + dateStr + '): ' + text;
|
||||
|
||||
var commentCount = -1;
|
||||
var insertIndex = -1;
|
||||
for (var 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');
|
||||
|
||||
var resp = await fetch(API_BASE_URL + '/issues/' + selectedReplyIssueId + '/management', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
|
||||
});
|
||||
|
||||
if (resp.ok) { showToast('답글이 추가되었습니다.', 'success'); closeSheet('reply'); await refreshData(); }
|
||||
else throw new Error('저장 실패');
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 수정 =====
|
||||
function openEditSheet(mode, issueId, opinionIndex, commentIndex, replyIndex) {
|
||||
editMode = mode;
|
||||
editIssueId = issueId;
|
||||
editOpinionIndex = opinionIndex;
|
||||
editCommentIndex = commentIndex !== undefined ? commentIndex : null;
|
||||
editReplyIndex = replyIndex !== undefined ? replyIndex : null;
|
||||
|
||||
var titles = { opinion: '의견 수정', comment: '댓글 수정', reply: '답글 수정' };
|
||||
document.getElementById('editSheetTitle').innerHTML = '<i class="fas fa-edit" style="color:#22c55e;margin-right:6px"></i>' + titles[mode];
|
||||
|
||||
// 현재 내용 로드
|
||||
fetch(API_BASE_URL + '/issues/' + issueId, {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
}).then(function (r) { return r.json(); }).then(function (issue) {
|
||||
var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
||||
var opinion = opinions[opinionIndex] || '';
|
||||
|
||||
if (mode === 'opinion') {
|
||||
var hm = opinion.trim().match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
|
||||
if (hm) {
|
||||
var restLines = opinion.trim().substring(hm[0].length).trim().split('\n');
|
||||
var main = restLines.filter(function (l) { return !l.match(/^\s*[└↳]/); }).join('\n');
|
||||
document.getElementById('editText').value = main;
|
||||
}
|
||||
} else if (mode === 'comment') {
|
||||
var cLines = opinion.trim().split('\n');
|
||||
var cc = -1;
|
||||
for (var i = 0; i < cLines.length; i++) {
|
||||
if (cLines[i].match(/^\s*└/)) {
|
||||
cc++;
|
||||
if (cc === commentIndex) {
|
||||
var m = cLines[i].match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
||||
if (m) document.getElementById('editText').value = m[3];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (mode === 'reply') {
|
||||
var rLines = opinion.trim().split('\n');
|
||||
var rc = -1;
|
||||
var ri = -1;
|
||||
for (var j = 0; j < rLines.length; j++) {
|
||||
if (rLines[j].match(/^\s*└/)) { rc++; ri = -1; }
|
||||
else if (rLines[j].match(/^\s*↳/)) {
|
||||
ri++;
|
||||
if (rc === commentIndex && ri === replyIndex) {
|
||||
var rm = rLines[j].match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
||||
if (rm) document.getElementById('editText').value = rm[3];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
openSheet('edit');
|
||||
});
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
var newText = document.getElementById('editText').value.trim();
|
||||
if (!newText) { showToast('내용을 입력해주세요.', 'warning'); return; }
|
||||
|
||||
try {
|
||||
var issue = await (await fetch(API_BASE_URL + '/issues/' + editIssueId, {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
})).json();
|
||||
|
||||
var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
||||
var lines = opinions[editOpinionIndex].trim().split('\n');
|
||||
|
||||
if (editMode === 'opinion') {
|
||||
var hm = opinions[editOpinionIndex].trim().match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
|
||||
if (hm) {
|
||||
var commentLines = lines.filter(function (l) { return l.match(/^\s*[└↳]/); });
|
||||
opinions[editOpinionIndex] = hm[0] + '\n' + newText + (commentLines.length ? '\n' + commentLines.join('\n') : '');
|
||||
}
|
||||
} else if (editMode === 'comment') {
|
||||
var cc = -1;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].match(/^\s*└/)) {
|
||||
cc++;
|
||||
if (cc === editCommentIndex) {
|
||||
var m = lines[i].match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):/);
|
||||
if (m) lines[i] = ' └ [' + m[1] + '] (' + m[2] + '): ' + newText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
opinions[editOpinionIndex] = lines.join('\n');
|
||||
} else if (editMode === 'reply') {
|
||||
var rc = -1, ri = -1;
|
||||
for (var j = 0; j < lines.length; j++) {
|
||||
if (lines[j].match(/^\s*└/)) { rc++; ri = -1; }
|
||||
else if (lines[j].match(/^\s*↳/)) {
|
||||
ri++;
|
||||
if (rc === editCommentIndex && ri === editReplyIndex) {
|
||||
var rm = lines[j].match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):/);
|
||||
if (rm) lines[j] = ' ↳ [' + rm[1] + '] (' + rm[2] + '): ' + newText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
opinions[editOpinionIndex] = lines.join('\n');
|
||||
}
|
||||
|
||||
var resp = await fetch(API_BASE_URL + '/issues/' + editIssueId + '/management', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
|
||||
});
|
||||
|
||||
if (resp.ok) { showToast('수정되었습니다.', 'success'); closeSheet('edit'); await refreshData(); }
|
||||
else throw new Error('저장 실패');
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 삭제 =====
|
||||
async function deleteOpinion(issueId, opinionIndex) {
|
||||
if (!confirm('이 의견을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
var issue = await (await fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json();
|
||||
var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
||||
opinions.splice(opinionIndex, 1);
|
||||
await fetch(API_BASE_URL + '/issues/' + issueId + '/management', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ solution: opinions.length ? opinions.join('\n' + '─'.repeat(50) + '\n') : null })
|
||||
});
|
||||
showToast('의견이 삭제되었습니다.', 'success');
|
||||
await refreshData();
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteComment(issueId, opinionIndex, commentIndex) {
|
||||
if (!confirm('이 댓글을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
var issue = await (await fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json();
|
||||
var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
||||
var lines = opinions[opinionIndex].trim().split('\n');
|
||||
var cc = -1, start = -1, end = -1;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].match(/^\s*└/)) { cc++; if (cc === commentIndex) { start = i; end = i + 1; while (end < lines.length && lines[end].match(/^\s*↳/)) end++; break; } }
|
||||
}
|
||||
if (start >= 0) { lines.splice(start, end - start); opinions[opinionIndex] = lines.join('\n'); }
|
||||
await fetch(API_BASE_URL + '/issues/' + issueId + '/management', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
|
||||
});
|
||||
showToast('댓글이 삭제되었습니다.', 'success');
|
||||
await refreshData();
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteReply(issueId, opinionIndex, commentIndex, replyIndex) {
|
||||
if (!confirm('이 답글을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
var issue = await (await fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json();
|
||||
var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
||||
var lines = opinions[opinionIndex].trim().split('\n');
|
||||
var cc = -1, ri = -1;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].match(/^\s*└/)) { cc++; ri = -1; }
|
||||
else if (lines[i].match(/^\s*↳/)) { ri++; if (cc === commentIndex && ri === replyIndex) { lines.splice(i, 1); break; } }
|
||||
}
|
||||
opinions[opinionIndex] = lines.join('\n');
|
||||
await fetch(API_BASE_URL + '/issues/' + issueId + '/management', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
|
||||
});
|
||||
showToast('답글이 삭제되었습니다.', 'success');
|
||||
await refreshData();
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 완료 신청 =====
|
||||
function openCompletionSheet(issueId) {
|
||||
selectedCompletionIssueId = issueId;
|
||||
completionPhotoBase64 = null;
|
||||
document.getElementById('completionPhotoInput').value = '';
|
||||
document.getElementById('completionPhotoPreview').classList.add('hidden');
|
||||
document.getElementById('completionComment').value = '';
|
||||
openSheet('completion');
|
||||
}
|
||||
|
||||
function handleCompletionPhoto(event) {
|
||||
var file = event.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) { showToast('파일 크기는 5MB 이하여야 합니다.', 'warning'); event.target.value = ''; return; }
|
||||
if (!file.type.startsWith('image/')) { showToast('이미지 파일만 가능합니다.', 'warning'); event.target.value = ''; return; }
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
completionPhotoBase64 = e.target.result.split(',')[1];
|
||||
var preview = document.getElementById('completionPhotoPreview');
|
||||
preview.src = e.target.result;
|
||||
preview.classList.remove('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
async function submitCompletionRequest() {
|
||||
if (!selectedCompletionIssueId) return;
|
||||
if (!completionPhotoBase64) { showToast('완료 사진을 업로드해주세요.', 'warning'); return; }
|
||||
|
||||
try {
|
||||
var body = { completion_photo: completionPhotoBase64 };
|
||||
var comment = document.getElementById('completionComment').value.trim();
|
||||
if (comment) body.completion_comment = comment;
|
||||
|
||||
var resp = await fetch(API_BASE_URL + '/issues/' + selectedCompletionIssueId + '/request-completion', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('완료 신청이 접수되었습니다.', 'success');
|
||||
closeSheet('completion');
|
||||
await refreshData();
|
||||
} else {
|
||||
var err = await resp.json();
|
||||
throw new Error(err.detail || '완료 신청 실패');
|
||||
}
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 완료 반려 =====
|
||||
function openRejectionSheet(issueId) {
|
||||
selectedRejectionIssueId = issueId;
|
||||
document.getElementById('rejectionReason').value = '';
|
||||
openSheet('rejection');
|
||||
}
|
||||
|
||||
async function submitRejection() {
|
||||
if (!selectedRejectionIssueId) return;
|
||||
var reason = document.getElementById('rejectionReason').value.trim();
|
||||
if (!reason) { showToast('반려 사유를 입력해주세요.', 'warning'); return; }
|
||||
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/issues/' + selectedRejectionIssueId + '/reject-completion', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rejection_reason: reason })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('완료 신청이 반려되었습니다.', 'success');
|
||||
closeSheet('rejection');
|
||||
await refreshData();
|
||||
} else {
|
||||
var err = await resp.json();
|
||||
throw new Error(err.detail || '반려 실패');
|
||||
}
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 새로고침 =====
|
||||
async function refreshData() {
|
||||
await loadIssues();
|
||||
filterByProject();
|
||||
updateStatistics();
|
||||
}
|
||||
|
||||
function refreshPage() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// ===== 시작 =====
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
352
system3-nonconformance/web/public/static/js/m/m-inbox.js
Normal file
352
system3-nonconformance/web/public/static/js/m/m-inbox.js
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* m-inbox.js — 수신함 모바일 페이지 로직
|
||||
*/
|
||||
|
||||
var currentUser = null;
|
||||
var issues = [];
|
||||
var projects = [];
|
||||
var filteredIssues = [];
|
||||
var currentIssueId = null;
|
||||
var statusPhotoBase64 = null;
|
||||
|
||||
// ===== 초기화 =====
|
||||
async function initialize() {
|
||||
currentUser = await mCheckAuth();
|
||||
if (!currentUser) return;
|
||||
|
||||
await loadProjects();
|
||||
await loadIssues();
|
||||
renderBottomNav('inbox');
|
||||
hideLoading();
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/projects/', {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
});
|
||||
if (resp.ok) {
|
||||
projects = await resp.json();
|
||||
var sel = document.getElementById('projectFilter');
|
||||
sel.innerHTML = '<option value="">전체 프로젝트</option>';
|
||||
projects.forEach(function (p) {
|
||||
sel.innerHTML += '<option value="' + p.id + '">' + escapeHtml(p.project_name) + '</option>';
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('프로젝트 로드 실패:', e); }
|
||||
}
|
||||
|
||||
async function loadIssues() {
|
||||
try {
|
||||
var pid = document.getElementById('projectFilter').value;
|
||||
var url = API_BASE_URL + '/inbox/' + (pid ? '?project_id=' + pid : '');
|
||||
var resp = await fetch(url, {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
});
|
||||
if (resp.ok) {
|
||||
issues = await resp.json();
|
||||
filterIssues();
|
||||
await loadStatistics();
|
||||
}
|
||||
} catch (e) { console.error('수신함 로드 실패:', e); }
|
||||
}
|
||||
|
||||
async function loadStatistics() {
|
||||
try {
|
||||
var todayStart = getKSTToday();
|
||||
|
||||
var todayNewCount = issues.filter(function (i) {
|
||||
var d = getKSTDate(new Date(i.report_date));
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()) >= todayStart;
|
||||
}).length;
|
||||
|
||||
var todayProcessedCount = 0;
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/inbox/statistics', {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
});
|
||||
if (resp.ok) { var s = await resp.json(); todayProcessedCount = s.today_processed || 0; }
|
||||
} catch (e) {}
|
||||
|
||||
var unresolvedCount = issues.filter(function (i) {
|
||||
var d = getKSTDate(new Date(i.report_date));
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()) < todayStart;
|
||||
}).length;
|
||||
|
||||
document.getElementById('todayNewCount').textContent = todayNewCount;
|
||||
document.getElementById('todayProcessedCount').textContent = todayProcessedCount;
|
||||
document.getElementById('unresolvedCount').textContent = unresolvedCount;
|
||||
} catch (e) { console.error('통계 로드 오류:', e); }
|
||||
}
|
||||
|
||||
function filterIssues() {
|
||||
var pid = document.getElementById('projectFilter').value;
|
||||
filteredIssues = pid ? issues.filter(function (i) { return i.project_id == pid; }) : issues.slice();
|
||||
filteredIssues.sort(function (a, b) { return new Date(b.report_date) - new Date(a.report_date); });
|
||||
renderIssues();
|
||||
}
|
||||
|
||||
// ===== 렌더링 =====
|
||||
function renderIssues() {
|
||||
var container = document.getElementById('issuesList');
|
||||
var empty = document.getElementById('emptyState');
|
||||
|
||||
if (!filteredIssues.length) { container.innerHTML = ''; empty.classList.remove('hidden'); return; }
|
||||
empty.classList.add('hidden');
|
||||
|
||||
container.innerHTML = filteredIssues.map(function (issue) {
|
||||
var project = projects.find(function (p) { return p.id === issue.project_id; });
|
||||
var photos = getPhotoPaths(issue);
|
||||
var photoCount = photos.length;
|
||||
|
||||
return '<div class="m-card border-blue">' +
|
||||
'<div class="m-card-header">' +
|
||||
'<div><span class="m-badge review"><i class="fas fa-clock"></i> 검토 대기</span>' +
|
||||
(project ? '<span class="m-card-project">' + escapeHtml(project.project_name) + '</span>' : '') +
|
||||
'</div>' +
|
||||
'<span style="font-size:11px;color:#9ca3af">ID: ' + issue.id + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="m-card-title text-ellipsis-3">' + escapeHtml(issue.final_description || issue.description) + '</div>' +
|
||||
'<div class="m-card-body">' +
|
||||
'<div style="display:flex;gap:12px;font-size:12px;color:#6b7280;margin-bottom:8px;flex-wrap:wrap">' +
|
||||
'<span><i class="fas fa-user" style="color:#3b82f6;margin-right:3px"></i>' + escapeHtml(issue.reporter?.username || '알 수 없음') + '</span>' +
|
||||
'<span><i class="fas fa-tag" style="color:#22c55e;margin-right:3px"></i>' + getCategoryText(issue.category || issue.final_category) + '</span>' +
|
||||
'<span><i class="fas fa-camera" style="color:#8b5cf6;margin-right:3px"></i>' + (photoCount > 0 ? photoCount + '장' : '없음') + '</span>' +
|
||||
'<span><i class="fas fa-clock" style="color:#f59e0b;margin-right:3px"></i>' + getTimeAgo(issue.report_date) + '</span>' +
|
||||
'</div>' +
|
||||
(photos.length ? renderPhotoThumbs(photos) : '') +
|
||||
(issue.detail_notes ? '<div style="font-size:12px;color:#6b7280;margin-top:6px;font-style:italic">"' + escapeHtml(issue.detail_notes) + '"</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="m-action-row">' +
|
||||
'<button class="m-action-btn red" onclick="openDisposeSheet(' + issue.id + ')"><i class="fas fa-trash"></i>폐기</button>' +
|
||||
'<button class="m-action-btn blue" onclick="openReviewSheet(' + issue.id + ')"><i class="fas fa-edit"></i>검토</button>' +
|
||||
'<button class="m-action-btn green" onclick="openStatusSheet(' + issue.id + ')"><i class="fas fa-check"></i>확인</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ===== 폐기 =====
|
||||
function openDisposeSheet(issueId) {
|
||||
currentIssueId = issueId;
|
||||
document.getElementById('disposalReason').value = 'duplicate';
|
||||
document.getElementById('customReason').value = '';
|
||||
document.getElementById('customReasonDiv').classList.add('hidden');
|
||||
document.getElementById('selectedDuplicateId').value = '';
|
||||
toggleDisposalFields();
|
||||
openSheet('dispose');
|
||||
loadManagementIssues();
|
||||
}
|
||||
|
||||
function toggleDisposalFields() {
|
||||
var reason = document.getElementById('disposalReason').value;
|
||||
document.getElementById('customReasonDiv').classList.toggle('hidden', reason !== 'custom');
|
||||
document.getElementById('duplicateDiv').classList.toggle('hidden', reason !== 'duplicate');
|
||||
if (reason === 'duplicate') loadManagementIssues();
|
||||
}
|
||||
|
||||
async function loadManagementIssues() {
|
||||
var issue = issues.find(function (i) { return i.id === currentIssueId; });
|
||||
var pid = issue ? issue.project_id : null;
|
||||
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/inbox/management-issues' + (pid ? '?project_id=' + pid : ''), {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
});
|
||||
if (!resp.ok) throw new Error('로드 실패');
|
||||
var list = await resp.json();
|
||||
var container = document.getElementById('managementIssuesList');
|
||||
|
||||
if (!list.length) {
|
||||
container.innerHTML = '<div style="padding:16px;text-align:center;color:#9ca3af;font-size:13px">동일 프로젝트의 관리함 이슈가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = list.map(function (mi) {
|
||||
return '<div style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:13px" onclick="selectDuplicate(' + mi.id + ',this)">' +
|
||||
'<div style="font-weight:500;color:#111827;margin-bottom:2px">' + escapeHtml(mi.description || mi.final_description) + '</div>' +
|
||||
'<div style="display:flex;gap:6px;color:#9ca3af;font-size:11px">' +
|
||||
'<span>' + getCategoryText(mi.category || mi.final_category) + '</span>' +
|
||||
'<span>신고자: ' + escapeHtml(mi.reporter_name) + '</span>' +
|
||||
'<span>ID: ' + mi.id + '</span>' +
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
document.getElementById('managementIssuesList').innerHTML = '<div style="padding:16px;text-align:center;color:#ef4444;font-size:13px">목록 로드 실패</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function selectDuplicate(id, el) {
|
||||
var items = document.getElementById('managementIssuesList').children;
|
||||
for (var i = 0; i < items.length; i++) items[i].style.background = '';
|
||||
el.style.background = '#eff6ff';
|
||||
document.getElementById('selectedDuplicateId').value = id;
|
||||
}
|
||||
|
||||
async function confirmDispose() {
|
||||
if (!currentIssueId) return;
|
||||
var reason = document.getElementById('disposalReason').value;
|
||||
var customReason = document.getElementById('customReason').value;
|
||||
var duplicateId = document.getElementById('selectedDuplicateId').value;
|
||||
|
||||
if (reason === 'custom' && !customReason.trim()) { showToast('폐기 사유를 입력해주세요.', 'warning'); return; }
|
||||
if (reason === 'duplicate' && !duplicateId) { showToast('중복 대상을 선택해주세요.', 'warning'); return; }
|
||||
|
||||
try {
|
||||
var body = { disposal_reason: reason, custom_disposal_reason: reason === 'custom' ? customReason : null };
|
||||
if (reason === 'duplicate' && duplicateId) body.duplicate_of_issue_id = parseInt(duplicateId);
|
||||
|
||||
var resp = await fetch(API_BASE_URL + '/inbox/' + currentIssueId + '/dispose', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('폐기 처리되었습니다.', 'success');
|
||||
closeSheet('dispose');
|
||||
await loadIssues();
|
||||
} else {
|
||||
var err = await resp.json();
|
||||
throw new Error(err.detail || '폐기 실패');
|
||||
}
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 검토 =====
|
||||
function openReviewSheet(issueId) {
|
||||
currentIssueId = issueId;
|
||||
var issue = issues.find(function (i) { return i.id === issueId; });
|
||||
if (!issue) return;
|
||||
|
||||
var project = projects.find(function (p) { return p.id === issue.project_id; });
|
||||
|
||||
// 원본 정보
|
||||
document.getElementById('originalInfo').innerHTML =
|
||||
'<div style="margin-bottom:4px"><strong>프로젝트:</strong> ' + (project ? escapeHtml(project.project_name) : '미지정') + '</div>' +
|
||||
'<div style="margin-bottom:4px"><strong>신고자:</strong> ' + escapeHtml(issue.reporter?.username || '알 수 없음') + '</div>' +
|
||||
'<div><strong>등록일:</strong> ' + formatKSTDate(issue.report_date) + '</div>';
|
||||
|
||||
// 프로젝트 select
|
||||
var sel = document.getElementById('reviewProjectId');
|
||||
sel.innerHTML = '<option value="">프로젝트 선택</option>';
|
||||
projects.forEach(function (p) {
|
||||
sel.innerHTML += '<option value="' + p.id + '"' + (p.id === issue.project_id ? ' selected' : '') + '>' + escapeHtml(p.project_name) + '</option>';
|
||||
});
|
||||
|
||||
document.getElementById('reviewCategory').value = issue.category || issue.final_category || 'etc';
|
||||
var desc = issue.description || issue.final_description || '';
|
||||
var lines = desc.split('\n');
|
||||
document.getElementById('reviewTitle').value = lines[0] || '';
|
||||
document.getElementById('reviewDescription').value = lines.slice(1).join('\n') || desc;
|
||||
|
||||
openSheet('review');
|
||||
}
|
||||
|
||||
async function saveReview() {
|
||||
if (!currentIssueId) return;
|
||||
var projectId = document.getElementById('reviewProjectId').value;
|
||||
var category = document.getElementById('reviewCategory').value;
|
||||
var title = document.getElementById('reviewTitle').value.trim();
|
||||
var description = document.getElementById('reviewDescription').value.trim();
|
||||
|
||||
if (!title) { showToast('부적합명을 입력해주세요.', 'warning'); return; }
|
||||
|
||||
var combined = title + (description ? '\n' + description : '');
|
||||
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/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: combined
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('검토가 완료되었습니다.', 'success');
|
||||
closeSheet('review');
|
||||
await loadIssues();
|
||||
} else {
|
||||
var err = await resp.json();
|
||||
throw new Error(err.detail || '검토 실패');
|
||||
}
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 확인 (상태 결정) =====
|
||||
function openStatusSheet(issueId) {
|
||||
currentIssueId = issueId;
|
||||
document.querySelectorAll('input[name="finalStatus"]').forEach(function (r) { r.checked = false; });
|
||||
document.querySelectorAll('.m-radio-item').forEach(function (el) { el.classList.remove('selected'); });
|
||||
document.getElementById('completionSection').classList.add('hidden');
|
||||
statusPhotoBase64 = null;
|
||||
document.getElementById('statusPhotoInput').value = '';
|
||||
document.getElementById('statusPhotoPreview').classList.add('hidden');
|
||||
document.getElementById('solutionInput').value = '';
|
||||
document.getElementById('responsibleDepartmentInput').value = '';
|
||||
document.getElementById('responsiblePersonInput').value = '';
|
||||
openSheet('status');
|
||||
}
|
||||
|
||||
function selectStatus(value) {
|
||||
document.querySelectorAll('.m-radio-item').forEach(function (el) { el.classList.remove('selected'); });
|
||||
var radio = document.querySelector('input[name="finalStatus"][value="' + value + '"]');
|
||||
if (radio) { radio.checked = true; radio.closest('.m-radio-item').classList.add('selected'); }
|
||||
document.getElementById('completionSection').classList.toggle('hidden', value !== 'completed');
|
||||
}
|
||||
|
||||
function handleStatusPhoto(event) {
|
||||
var file = event.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) { showToast('5MB 이하 파일만 가능합니다.', 'warning'); event.target.value = ''; return; }
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
statusPhotoBase64 = e.target.result.split(',')[1];
|
||||
var preview = document.getElementById('statusPhotoPreview');
|
||||
preview.src = e.target.result;
|
||||
preview.classList.remove('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
async function confirmStatus() {
|
||||
if (!currentIssueId) return;
|
||||
var selected = document.querySelector('input[name="finalStatus"]:checked');
|
||||
if (!selected) { showToast('상태를 선택해주세요.', 'warning'); return; }
|
||||
|
||||
var reviewStatus = selected.value;
|
||||
var body = { review_status: reviewStatus };
|
||||
|
||||
if (reviewStatus === 'completed') {
|
||||
var solution = document.getElementById('solutionInput').value.trim();
|
||||
var dept = document.getElementById('responsibleDepartmentInput').value;
|
||||
var person = document.getElementById('responsiblePersonInput').value.trim();
|
||||
if (solution) body.solution = solution;
|
||||
if (dept) body.responsible_department = dept;
|
||||
if (person) body.responsible_person = person;
|
||||
if (statusPhotoBase64) body.completion_photo = statusPhotoBase64;
|
||||
}
|
||||
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/inbox/' + currentIssueId + '/status', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('상태가 변경되었습니다.', 'success');
|
||||
closeSheet('status');
|
||||
await loadIssues();
|
||||
} else {
|
||||
var err = await resp.json();
|
||||
throw new Error(err.detail || '상태 변경 실패');
|
||||
}
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 시작 =====
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
538
system3-nonconformance/web/public/static/js/m/m-management.js
Normal file
538
system3-nonconformance/web/public/static/js/m/m-management.js
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* m-management.js — 관리함 모바일 페이지 로직
|
||||
*/
|
||||
|
||||
var currentUser = null;
|
||||
var issues = [];
|
||||
var projects = [];
|
||||
var filteredIssues = [];
|
||||
var currentTab = 'in_progress';
|
||||
var currentIssueId = null;
|
||||
var rejectIssueId = null;
|
||||
|
||||
function cleanManagementComment(text) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\[완료 반려[^\]]*\][^\n]*\n*/g, '').trim();
|
||||
}
|
||||
|
||||
// ===== 초기화 =====
|
||||
async function initialize() {
|
||||
currentUser = await mCheckAuth();
|
||||
if (!currentUser) return;
|
||||
|
||||
await loadProjects();
|
||||
await loadIssues();
|
||||
renderBottomNav('management');
|
||||
hideLoading();
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/projects/', {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
});
|
||||
if (resp.ok) {
|
||||
projects = await resp.json();
|
||||
var sel = document.getElementById('projectFilter');
|
||||
sel.innerHTML = '<option value="">전체 프로젝트</option>';
|
||||
projects.forEach(function (p) {
|
||||
sel.innerHTML += '<option value="' + p.id + '">' + escapeHtml(p.project_name) + '</option>';
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('프로젝트 로드 실패:', e); }
|
||||
}
|
||||
|
||||
async function loadIssues() {
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/issues/admin/all', {
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
|
||||
});
|
||||
if (resp.ok) {
|
||||
var all = await resp.json();
|
||||
var filtered = all.filter(function (i) { return i.review_status === 'in_progress' || i.review_status === 'completed'; });
|
||||
|
||||
// 프로젝트별 순번
|
||||
filtered.sort(function (a, b) { return new Date(a.reviewed_at) - new Date(b.reviewed_at); });
|
||||
var groups = {};
|
||||
filtered.forEach(function (issue) {
|
||||
if (!groups[issue.project_id]) groups[issue.project_id] = [];
|
||||
groups[issue.project_id].push(issue);
|
||||
});
|
||||
Object.keys(groups).forEach(function (pid) {
|
||||
groups[pid].forEach(function (issue, idx) { issue.project_sequence_no = idx + 1; });
|
||||
});
|
||||
|
||||
issues = filtered;
|
||||
filterIssues();
|
||||
}
|
||||
} catch (e) { console.error('이슈 로드 실패:', e); }
|
||||
}
|
||||
|
||||
// ===== 탭 전환 =====
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.getElementById('tabInProgress').classList.toggle('active', tab === 'in_progress');
|
||||
document.getElementById('tabCompleted').classList.toggle('active', tab === 'completed');
|
||||
document.getElementById('additionalInfoBtn').style.display = tab === 'in_progress' ? 'flex' : 'none';
|
||||
filterIssues();
|
||||
}
|
||||
|
||||
// ===== 통계 =====
|
||||
function updateStatistics() {
|
||||
var pid = document.getElementById('projectFilter').value;
|
||||
var pi = pid ? issues.filter(function (i) { return i.project_id == pid; }) : issues;
|
||||
|
||||
document.getElementById('totalCount').textContent = pi.length;
|
||||
document.getElementById('inProgressCount').textContent = pi.filter(function (i) { return i.review_status === 'in_progress' && !i.completion_requested_at; }).length;
|
||||
document.getElementById('pendingCompletionCount').textContent = pi.filter(function (i) { return i.review_status === 'in_progress' && i.completion_requested_at; }).length;
|
||||
document.getElementById('completedCount').textContent = pi.filter(function (i) { return i.review_status === 'completed'; }).length;
|
||||
}
|
||||
|
||||
// ===== 필터 =====
|
||||
function filterIssues() {
|
||||
var pid = document.getElementById('projectFilter').value;
|
||||
filteredIssues = issues.filter(function (i) {
|
||||
if (i.review_status !== currentTab) return false;
|
||||
if (pid && i.project_id != pid) return false;
|
||||
return true;
|
||||
});
|
||||
filteredIssues.sort(function (a, b) { return new Date(b.report_date) - new Date(a.report_date); });
|
||||
renderIssues();
|
||||
updateStatistics();
|
||||
}
|
||||
|
||||
// ===== 이슈 상태 =====
|
||||
function getIssueStatus(issue) {
|
||||
if (issue.review_status === 'completed') return 'completed';
|
||||
if (issue.completion_requested_at) return 'pending_completion';
|
||||
if (issue.expected_completion_date) {
|
||||
var diff = (new Date(issue.expected_completion_date) - new Date()) / 86400000;
|
||||
if (diff < 0) return 'overdue';
|
||||
if (diff <= 3) return 'urgent';
|
||||
}
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
function getStatusBadgeHtml(status) {
|
||||
var map = {
|
||||
'in_progress': '<span class="m-badge in-progress"><i class="fas fa-cog"></i> 진행 중</span>',
|
||||
'urgent': '<span class="m-badge urgent"><i class="fas fa-exclamation-triangle"></i> 긴급</span>',
|
||||
'overdue': '<span class="m-badge overdue"><i class="fas fa-clock"></i> 지연됨</span>',
|
||||
'pending_completion': '<span class="m-badge pending-completion"><i class="fas fa-hourglass-half"></i> 완료 대기</span>',
|
||||
'completed': '<span class="m-badge completed"><i class="fas fa-check-circle"></i> 완료됨</span>'
|
||||
};
|
||||
return map[status] || map['in_progress'];
|
||||
}
|
||||
|
||||
// ===== 렌더링 =====
|
||||
function renderIssues() {
|
||||
var container = document.getElementById('issuesList');
|
||||
var empty = document.getElementById('emptyState');
|
||||
|
||||
if (!filteredIssues.length) { container.innerHTML = ''; empty.classList.remove('hidden'); return; }
|
||||
empty.classList.add('hidden');
|
||||
|
||||
// 날짜별 그룹
|
||||
var grouped = {};
|
||||
var dateObjs = {};
|
||||
filteredIssues.forEach(function (issue) {
|
||||
var dateToUse = currentTab === 'completed' ? (issue.actual_completion_date || issue.report_date) : issue.report_date;
|
||||
var d = new Date(dateToUse);
|
||||
var key = d.toLocaleDateString('ko-KR');
|
||||
if (!grouped[key]) { grouped[key] = []; dateObjs[key] = d; }
|
||||
grouped[key].push(issue);
|
||||
});
|
||||
|
||||
var html = Object.keys(grouped)
|
||||
.sort(function (a, b) { return dateObjs[b] - dateObjs[a]; })
|
||||
.map(function (dateKey) {
|
||||
var issues = grouped[dateKey];
|
||||
return '<div class="m-date-group"><div class="m-date-header">' +
|
||||
'<i class="fas fa-calendar-alt"></i>' +
|
||||
'<span>' + dateKey + '</span>' +
|
||||
'<span class="m-date-count">(' + issues.length + '건)</span>' +
|
||||
'<span style="font-size:10px;padding:2px 6px;border-radius:8px;background:' +
|
||||
(currentTab === 'in_progress' ? '#dbeafe;color:#1d4ed8' : '#dcfce7;color:#15803d') + '">' +
|
||||
(currentTab === 'in_progress' ? '업로드일' : '완료일') + '</span>' +
|
||||
'</div>' +
|
||||
issues.map(function (issue) {
|
||||
return currentTab === 'in_progress' ? renderInProgressCard(issue) : renderCompletedCard(issue);
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderInProgressCard(issue) {
|
||||
var project = projects.find(function (p) { return p.id === issue.project_id; });
|
||||
var status = getIssueStatus(issue);
|
||||
var isPending = status === 'pending_completion';
|
||||
var photos = getPhotoPaths(issue);
|
||||
|
||||
// 관리 필드 표시
|
||||
var mgmtHtml = '<div style="margin-top:8px">' +
|
||||
'<div class="m-info-row"><i class="fas fa-lightbulb" style="color:#eab308"></i><span style="font-weight:600">해결방안:</span> <span>' + escapeHtml(cleanManagementComment(issue.management_comment) || '-') + '</span></div>' +
|
||||
'<div class="m-info-row"><i class="fas fa-building" style="color:#3b82f6"></i><span style="font-weight:600">담당부서:</span> <span>' + getDepartmentText(issue.responsible_department) + '</span></div>' +
|
||||
'<div class="m-info-row"><i class="fas fa-user" style="color:#8b5cf6"></i><span style="font-weight:600">담당자:</span> <span>' + escapeHtml(issue.responsible_person || '-') + '</span></div>' +
|
||||
'<div class="m-info-row"><i class="fas fa-calendar-alt" style="color:#ef4444"></i><span style="font-weight:600">조치예상일:</span> <span>' + (issue.expected_completion_date ? formatKSTDate(issue.expected_completion_date) : '-') + '</span></div>' +
|
||||
'</div>';
|
||||
|
||||
// 완료 대기 정보
|
||||
var completionInfoHtml = '';
|
||||
if (isPending) {
|
||||
var cPhotos = getCompletionPhotoPaths(issue);
|
||||
completionInfoHtml = '<div class="m-completion-info" style="margin-top:8px">' +
|
||||
'<div style="font-size:12px;font-weight:600;color:#6d28d9;margin-bottom:4px"><i class="fas fa-check-circle" style="margin-right:4px"></i>완료 신청 정보</div>' +
|
||||
(cPhotos.length ? renderPhotoThumbs(cPhotos) : '') +
|
||||
'<div style="font-size:12px;color:#6b7280;margin-top:4px">' + escapeHtml(issue.completion_comment || '코멘트 없음') + '</div>' +
|
||||
'<div style="font-size:11px;color:#9ca3af;margin-top:2px">신청: ' + formatKSTDateTime(issue.completion_requested_at) + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 액션 버튼
|
||||
var actionHtml = '';
|
||||
if (isPending) {
|
||||
actionHtml = '<div class="m-action-row">' +
|
||||
'<button class="m-action-btn red" onclick="event.stopPropagation();openRejectSheet(' + issue.id + ')"><i class="fas fa-times"></i>반려</button>' +
|
||||
'<button class="m-action-btn green" onclick="event.stopPropagation();confirmCompletion(' + issue.id + ')"><i class="fas fa-check-circle"></i>최종확인</button>' +
|
||||
'</div>';
|
||||
} else {
|
||||
actionHtml = '<div class="m-action-row">' +
|
||||
'<button class="m-action-btn blue" onclick="event.stopPropagation();openEditMgmtSheet(' + issue.id + ')"><i class="fas fa-edit"></i>편집</button>' +
|
||||
'<button class="m-action-btn green" onclick="event.stopPropagation();confirmCompletion(' + issue.id + ')"><i class="fas fa-check"></i>완료처리</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return '<div class="m-card border-blue">' +
|
||||
'<div class="m-card-header">' +
|
||||
'<div><span class="m-card-no">No.' + (issue.project_sequence_no || '-') + '</span>' +
|
||||
'<span class="m-card-project">' + escapeHtml(project ? project.project_name : '미지정') + '</span></div>' +
|
||||
getStatusBadgeHtml(status) +
|
||||
'</div>' +
|
||||
'<div class="m-card-title">' + escapeHtml(getIssueTitle(issue)) + '</div>' +
|
||||
'<div class="m-card-body">' +
|
||||
'<div style="font-size:13px;color:#6b7280;line-height:1.5;margin-bottom:6px" class="text-ellipsis-3">' + escapeHtml(getIssueDetail(issue)) + '</div>' +
|
||||
'<div style="display:flex;gap:8px;font-size:12px;color:#9ca3af;margin-bottom:6px">' +
|
||||
'<span><i class="fas fa-tag" style="margin-right:3px"></i>' + getCategoryText(issue.category || issue.final_category) + '</span>' +
|
||||
'<span><i class="fas fa-user" style="margin-right:3px"></i>' + escapeHtml(issue.reporter?.full_name || issue.reporter?.username || '-') + '</span>' +
|
||||
'</div>' +
|
||||
(photos.length ? renderPhotoThumbs(photos) : '') +
|
||||
mgmtHtml +
|
||||
completionInfoHtml +
|
||||
'</div>' +
|
||||
actionHtml +
|
||||
'<div class="m-card-footer">' +
|
||||
'<span>신고일: ' + formatKSTDate(issue.report_date) + '</span>' +
|
||||
'<span>ID: ' + issue.id + '</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderCompletedCard(issue) {
|
||||
var project = projects.find(function (p) { return p.id === issue.project_id; });
|
||||
var completedDate = issue.completed_at ? formatKSTDate(issue.completed_at) : '-';
|
||||
|
||||
return '<div class="m-card border-green" onclick="openDetailSheet(' + issue.id + ')">' +
|
||||
'<div class="m-card-header">' +
|
||||
'<div><span class="m-card-no" style="color:#16a34a">No.' + (issue.project_sequence_no || '-') + '</span>' +
|
||||
'<span class="m-card-project">' + escapeHtml(project ? project.project_name : '미지정') + '</span></div>' +
|
||||
'<span class="m-badge completed"><i class="fas fa-check-circle"></i> 완료</span>' +
|
||||
'</div>' +
|
||||
'<div class="m-card-title">' + escapeHtml(getIssueTitle(issue)) + '</div>' +
|
||||
'<div class="m-card-footer">' +
|
||||
'<span>완료일: ' + completedDate + '</span>' +
|
||||
'<span style="color:#3b82f6"><i class="fas fa-chevron-right" style="font-size:10px"></i> 상세보기</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ===== 편집 시트 =====
|
||||
function openEditMgmtSheet(issueId) {
|
||||
currentIssueId = issueId;
|
||||
var issue = issues.find(function (i) { return i.id === issueId; });
|
||||
if (!issue) return;
|
||||
|
||||
// 프로젝트 셀렉트 채우기
|
||||
var projSel = document.getElementById('editProject');
|
||||
projSel.innerHTML = '<option value="">선택하세요</option>';
|
||||
projects.forEach(function (p) {
|
||||
projSel.innerHTML += '<option value="' + p.id + '"' + (p.id == issue.project_id ? ' selected' : '') + '>' + escapeHtml(p.project_name || p.job_no) + '</option>';
|
||||
});
|
||||
projSel.disabled = (issue.review_status === 'completed');
|
||||
|
||||
document.getElementById('editManagementComment').value = cleanManagementComment(issue.management_comment) || '';
|
||||
document.getElementById('editResponsibleDept').value = issue.responsible_department || '';
|
||||
document.getElementById('editResponsiblePerson').value = issue.responsible_person || '';
|
||||
document.getElementById('editExpectedDate').value = issue.expected_completion_date ? issue.expected_completion_date.split('T')[0] : '';
|
||||
|
||||
// 원본 사진 보충 UI 초기화
|
||||
var slotKeys = ['photo_path', 'photo_path2', 'photo_path3', 'photo_path4', 'photo_path5'];
|
||||
var existingPhotos = slotKeys.map(function (k) { return issue[k]; }).filter(function (p) { return p; });
|
||||
var emptyCount = 5 - existingPhotos.length;
|
||||
|
||||
var existingEl = document.getElementById('editExistingPhotos');
|
||||
existingEl.innerHTML = existingPhotos.length
|
||||
? existingPhotos.map(function (p) {
|
||||
return '<img src="' + escapeHtml(p) + '" style="width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid #d1d5db" alt="기존 사진">';
|
||||
}).join('')
|
||||
: '<span style="font-size:12px;color:#9ca3af">기존 사진 없음</span>';
|
||||
|
||||
var slotInfoEl = document.getElementById('editPhotoSlotInfo');
|
||||
slotInfoEl.textContent = emptyCount > 0 ? '(남은 슬롯: ' + emptyCount + '장)' : '(가득 참)';
|
||||
|
||||
var photoInput = document.getElementById('editPhotoInput');
|
||||
photoInput.value = '';
|
||||
photoInput.disabled = (emptyCount === 0);
|
||||
document.getElementById('editPhotoPreview').innerHTML = '';
|
||||
|
||||
openSheet('editMgmt');
|
||||
}
|
||||
|
||||
// 파일 input change 시 미리보기 렌더
|
||||
function previewEditPhotos(event) {
|
||||
var files = event.target.files;
|
||||
var preview = document.getElementById('editPhotoPreview');
|
||||
preview.innerHTML = '';
|
||||
if (!files || !files.length) return;
|
||||
|
||||
Array.prototype.forEach.call(files, function (file) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
var img = document.createElement('img');
|
||||
img.src = e.target.result;
|
||||
img.style.cssText = 'width:52px;height:52px;object-fit:cover;border-radius:6px;border:2px solid #10b981';
|
||||
img.alt = '추가 예정';
|
||||
preview.appendChild(img);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function fileToBase64(file) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) { resolve(e.target.result); };
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveManagementEdit() {
|
||||
if (!currentIssueId) return;
|
||||
try {
|
||||
var updates = {
|
||||
management_comment: document.getElementById('editManagementComment').value.trim() || null,
|
||||
responsible_department: document.getElementById('editResponsibleDept').value || null,
|
||||
responsible_person: document.getElementById('editResponsiblePerson').value.trim() || null,
|
||||
expected_completion_date: document.getElementById('editExpectedDate').value ? document.getElementById('editExpectedDate').value + 'T00:00:00' : null
|
||||
};
|
||||
|
||||
// 원본 사진 보충 — 빈 슬롯에만 채움
|
||||
var photoInput = document.getElementById('editPhotoInput');
|
||||
if (photoInput && photoInput.files && photoInput.files.length > 0) {
|
||||
var currentIssue = issues.find(function (i) { return i.id === currentIssueId; });
|
||||
if (currentIssue) {
|
||||
var slotKeys = ['photo_path', 'photo_path2', 'photo_path3', 'photo_path4', 'photo_path5'];
|
||||
var emptySlots = [];
|
||||
slotKeys.forEach(function (k, idx) { if (!currentIssue[k]) emptySlots.push(idx + 1); });
|
||||
|
||||
if (emptySlots.length === 0) {
|
||||
showToast('원본 사진 슬롯이 가득 찼습니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
var filesToUpload = Array.prototype.slice.call(photoInput.files, 0, emptySlots.length);
|
||||
if (photoInput.files.length > emptySlots.length) {
|
||||
showToast('빈 슬롯 ' + emptySlots.length + '장 중 처음 ' + emptySlots.length + '장만 업로드됩니다', 'info');
|
||||
}
|
||||
|
||||
for (var i = 0; i < filesToUpload.length; i++) {
|
||||
var base64 = await fileToBase64(filesToUpload[i]);
|
||||
var slotNum = emptySlots[i];
|
||||
var fieldName = slotNum === 1 ? 'photo' : 'photo' + slotNum;
|
||||
updates[fieldName] = base64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 변경 확인
|
||||
var newProjectId = parseInt(document.getElementById('editProject').value);
|
||||
var issue = issues.find(function (i) { return i.id === currentIssueId; });
|
||||
if (newProjectId && issue && newProjectId !== issue.project_id) {
|
||||
// 프로젝트 변경은 /issues/{id} PUT으로 별도 호출
|
||||
var projResp = await fetch(API_BASE_URL + '/issues/' + currentIssueId, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project_id: newProjectId })
|
||||
});
|
||||
if (!projResp.ok) {
|
||||
var projErr = await projResp.json();
|
||||
throw new Error(projErr.detail || '프로젝트 변경 실패');
|
||||
}
|
||||
}
|
||||
|
||||
var resp = await fetch(API_BASE_URL + '/issues/' + currentIssueId + '/management', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('저장되었습니다.', 'success');
|
||||
closeSheet('editMgmt');
|
||||
await loadIssues();
|
||||
} else {
|
||||
var err = await resp.json();
|
||||
throw new Error(err.detail || '저장 실패');
|
||||
}
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 완료 처리 =====
|
||||
async function confirmCompletion(issueId) {
|
||||
if (!confirm('완료 처리하시겠습니까?')) return;
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/inbox/' + issueId + '/status', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ review_status: 'completed' })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('완료 처리되었습니다.', 'success');
|
||||
await loadIssues();
|
||||
} else {
|
||||
var err = await resp.json();
|
||||
throw new Error(err.detail || '완료 처리 실패');
|
||||
}
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 반려 =====
|
||||
function openRejectSheet(issueId) {
|
||||
rejectIssueId = issueId;
|
||||
document.getElementById('rejectReason').value = '';
|
||||
openSheet('reject');
|
||||
}
|
||||
|
||||
async function submitReject() {
|
||||
if (!rejectIssueId) return;
|
||||
var reason = document.getElementById('rejectReason').value.trim();
|
||||
if (!reason) { showToast('반려 사유를 입력해주세요.', 'warning'); return; }
|
||||
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/issues/' + rejectIssueId + '/reject-completion', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rejection_reason: reason })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('반려 처리되었습니다.', 'success');
|
||||
closeSheet('reject');
|
||||
await loadIssues();
|
||||
} else {
|
||||
var err = await resp.json();
|
||||
throw new Error(err.detail || '반려 실패');
|
||||
}
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 추가 정보 =====
|
||||
function openAdditionalInfoSheet() {
|
||||
var inProgressIssues = issues.filter(function (i) { return i.review_status === 'in_progress'; });
|
||||
var sel = document.getElementById('additionalIssueSelect');
|
||||
sel.innerHTML = '<option value="">이슈 선택</option>';
|
||||
inProgressIssues.forEach(function (i) {
|
||||
var p = projects.find(function (pr) { return pr.id === i.project_id; });
|
||||
sel.innerHTML += '<option value="' + i.id + '">No.' + (i.project_sequence_no || '-') + ' ' + escapeHtml(getIssueTitle(i)) + '</option>';
|
||||
});
|
||||
document.getElementById('additionalCauseDept').value = '';
|
||||
document.getElementById('additionalCausePerson').value = '';
|
||||
document.getElementById('additionalCauseDetail').value = '';
|
||||
openSheet('additional');
|
||||
}
|
||||
|
||||
function loadAdditionalInfo() {
|
||||
var id = parseInt(document.getElementById('additionalIssueSelect').value);
|
||||
if (!id) return;
|
||||
var issue = issues.find(function (i) { return i.id === id; });
|
||||
if (!issue) return;
|
||||
document.getElementById('additionalCauseDept').value = issue.cause_department || '';
|
||||
document.getElementById('additionalCausePerson').value = issue.responsible_person_detail || '';
|
||||
document.getElementById('additionalCauseDetail').value = issue.cause_detail || '';
|
||||
}
|
||||
|
||||
async function saveAdditionalInfo() {
|
||||
var id = parseInt(document.getElementById('additionalIssueSelect').value);
|
||||
if (!id) { showToast('이슈를 선택해주세요.', 'warning'); return; }
|
||||
|
||||
try {
|
||||
var resp = await fetch(API_BASE_URL + '/issues/' + id + '/management', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
cause_department: document.getElementById('additionalCauseDept').value || null,
|
||||
responsible_person_detail: document.getElementById('additionalCausePerson').value.trim() || null,
|
||||
cause_detail: document.getElementById('additionalCauseDetail').value.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast('추가 정보가 저장되었습니다.', 'success');
|
||||
closeSheet('additional');
|
||||
await loadIssues();
|
||||
} else {
|
||||
var err = await resp.json();
|
||||
throw new Error(err.detail || '저장 실패');
|
||||
}
|
||||
} catch (e) { showToast('오류: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 완료됨 상세보기 =====
|
||||
function openDetailSheet(issueId) {
|
||||
var issue = issues.find(function (i) { return i.id === issueId; });
|
||||
if (!issue) return;
|
||||
var project = projects.find(function (p) { return p.id === issue.project_id; });
|
||||
var photos = getPhotoPaths(issue);
|
||||
var cPhotos = getCompletionPhotoPaths(issue);
|
||||
|
||||
document.getElementById('detailSheetTitle').innerHTML =
|
||||
'<span style="font-weight:800;color:#16a34a">No.' + (issue.project_sequence_no || '-') + '</span> 상세 정보';
|
||||
|
||||
document.getElementById('detailSheetBody').innerHTML =
|
||||
// 기본 정보
|
||||
'<div style="margin-bottom:16px">' +
|
||||
'<div style="font-size:14px;font-weight:700;color:#111827;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #e5e7eb"><i class="fas fa-info-circle" style="color:#3b82f6;margin-right:6px"></i>기본 정보</div>' +
|
||||
'<div class="m-info-row"><span style="font-weight:600">프로젝트:</span> <span>' + escapeHtml(project ? project.project_name : '-') + '</span></div>' +
|
||||
'<div class="m-info-row"><span style="font-weight:600">부적합명:</span> <span>' + escapeHtml(getIssueTitle(issue)) + '</span></div>' +
|
||||
'<div style="font-size:13px;color:#6b7280;line-height:1.5;margin:6px 0;white-space:pre-wrap">' + escapeHtml(getIssueDetail(issue)) + '</div>' +
|
||||
'<div class="m-info-row"><span style="font-weight:600">분류:</span> <span>' + getCategoryText(issue.final_category || issue.category) + '</span></div>' +
|
||||
'<div class="m-info-row"><span style="font-weight:600">확인자:</span> <span>' + escapeHtml(getReporterNames(issue)) + '</span></div>' +
|
||||
(photos.length ? '<div style="margin-top:6px"><div style="font-size:12px;font-weight:600;color:#6b7280;margin-bottom:4px">업로드 사진</div>' + renderPhotoThumbs(photos) + '</div>' : '') +
|
||||
'</div>' +
|
||||
|
||||
// 관리 정보
|
||||
'<div style="margin-bottom:16px">' +
|
||||
'<div style="font-size:14px;font-weight:700;color:#111827;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #e5e7eb"><i class="fas fa-cogs" style="color:#3b82f6;margin-right:6px"></i>관리 정보</div>' +
|
||||
'<div class="m-info-row"><span style="font-weight:600">해결방안:</span> <span>' + escapeHtml(cleanManagementComment(issue.management_comment) || '-') + '</span></div>' +
|
||||
'<div class="m-info-row"><span style="font-weight:600">담당부서:</span> <span>' + getDepartmentText(issue.responsible_department) + '</span></div>' +
|
||||
'<div class="m-info-row"><span style="font-weight:600">담당자:</span> <span>' + escapeHtml(issue.responsible_person || '-') + '</span></div>' +
|
||||
'<div class="m-info-row"><span style="font-weight:600">원인부서:</span> <span>' + getDepartmentText(issue.cause_department) + '</span></div>' +
|
||||
'</div>' +
|
||||
|
||||
// 완료 정보
|
||||
'<div>' +
|
||||
'<div style="font-size:14px;font-weight:700;color:#111827;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #e5e7eb"><i class="fas fa-check-circle" style="color:#22c55e;margin-right:6px"></i>완료 정보</div>' +
|
||||
(cPhotos.length ? '<div style="margin-bottom:6px"><div style="font-size:12px;font-weight:600;color:#6b7280;margin-bottom:4px">완료 사진</div>' + renderPhotoThumbs(cPhotos) + '</div>' : '<div class="m-info-row"><span>완료 사진 없음</span></div>') +
|
||||
'<div class="m-info-row"><span style="font-weight:600">완료 코멘트:</span> <span>' + escapeHtml(issue.completion_comment || '-') + '</span></div>' +
|
||||
(issue.completion_requested_at ? '<div class="m-info-row"><span style="font-weight:600">완료 신청일:</span> <span>' + formatKSTDateTime(issue.completion_requested_at) + '</span></div>' : '') +
|
||||
(issue.completed_at ? '<div class="m-info-row"><span style="font-weight:600">최종 완료일:</span> <span>' + formatKSTDateTime(issue.completed_at) + '</span></div>' : '') +
|
||||
'</div>';
|
||||
|
||||
openSheet('detail');
|
||||
}
|
||||
|
||||
// ===== 시작 =====
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* ai-assistant.js — AI 어시스턴트 페이지 스크립트
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
let projects = [];
|
||||
let chatHistory = [];
|
||||
|
||||
// 애니메이션 함수들
|
||||
function animateHeaderAppearance() {
|
||||
const header = document.getElementById('commonHeader');
|
||||
if (header) {
|
||||
header.classList.add('header-fade-in');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
async function initializeAiAssistant() {
|
||||
try {
|
||||
currentUser = await window.authManager.checkAuth();
|
||||
if (!currentUser) {
|
||||
document.getElementById('loadingScreen').style.display = 'none';
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
window.pagePermissionManager.setUser(currentUser);
|
||||
await window.pagePermissionManager.loadPagePermissions();
|
||||
|
||||
if (!window.pagePermissionManager.canAccessPage('ai_assistant')) {
|
||||
alert('AI 어시스턴트 접근 권한이 없습니다.');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.commonHeader) {
|
||||
await window.commonHeader.init(currentUser, 'ai_assistant');
|
||||
setTimeout(() => animateHeaderAppearance(), 100);
|
||||
}
|
||||
|
||||
await loadProjects();
|
||||
checkAiHealth();
|
||||
document.getElementById('loadingScreen').style.display = 'none';
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI 어시스턴트 초기화 실패:', error);
|
||||
alert('페이지를 불러오는데 실패했습니다.');
|
||||
document.getElementById('loadingScreen').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// AuthManager 대기 후 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const checkAuthManager = () => {
|
||||
if (window.authManager) {
|
||||
initializeAiAssistant();
|
||||
} else {
|
||||
setTimeout(checkAuthManager, 100);
|
||||
}
|
||||
};
|
||||
checkAuthManager();
|
||||
});
|
||||
|
||||
// 프로젝트 로드
|
||||
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();
|
||||
updateProjectFilters();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateProjectFilters() {
|
||||
const selects = ['qaProjectFilter', 'searchProjectFilter'];
|
||||
selects.forEach(id => {
|
||||
const select = document.getElementById(id);
|
||||
if (!select) return;
|
||||
// 기존 옵션 유지 (첫 번째 "전체 프로젝트")
|
||||
while (select.options.length > 1) select.remove(1);
|
||||
projects.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.project_name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── AI 상태 체크 ───────────────────────────────────────
|
||||
async function checkAiHealth() {
|
||||
try {
|
||||
const health = await AiAPI.checkHealth();
|
||||
const statusText = document.getElementById('aiStatusText');
|
||||
const statusIcon = document.getElementById('aiStatusIcon');
|
||||
const embeddingCount = document.getElementById('aiEmbeddingCount');
|
||||
const modelName = document.getElementById('aiModelName');
|
||||
|
||||
if (health.status === 'healthy' || health.status === 'ok') {
|
||||
statusText.textContent = '연결됨';
|
||||
statusText.classList.add('text-green-600');
|
||||
statusIcon.innerHTML = '<i class="fas fa-check-circle text-green-500 text-xl"></i>';
|
||||
statusIcon.className = 'w-10 h-10 rounded-full bg-green-50 flex items-center justify-center';
|
||||
} else {
|
||||
statusText.textContent = '연결 안됨';
|
||||
statusText.classList.add('text-red-500');
|
||||
statusIcon.innerHTML = '<i class="fas fa-times-circle text-red-500 text-xl"></i>';
|
||||
statusIcon.className = 'w-10 h-10 rounded-full bg-red-50 flex items-center justify-center';
|
||||
}
|
||||
|
||||
const embCount = health.embedding_count
|
||||
?? health.total_embeddings
|
||||
?? health.embeddings?.total_documents;
|
||||
if (embCount !== undefined) {
|
||||
embeddingCount.textContent = embCount.toLocaleString() + '건';
|
||||
}
|
||||
|
||||
const model = health.model
|
||||
|| health.llm_model
|
||||
|| health.ollama?.ollama?.models?.[0]
|
||||
|| health.ollama?.models?.[0];
|
||||
if (model) {
|
||||
modelName.textContent = model;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI 상태 체크 실패:', error);
|
||||
document.getElementById('aiStatusText').textContent = '오류';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Q&A 채팅 ───────────────────────────────────────────
|
||||
function setQuickQuestion(text) {
|
||||
document.getElementById('qaQuestion').value = text;
|
||||
document.getElementById('qaQuestion').focus();
|
||||
}
|
||||
|
||||
async function submitQuestion() {
|
||||
const input = document.getElementById('qaQuestion');
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
|
||||
const projectId = document.getElementById('qaProjectFilter').value || null;
|
||||
|
||||
// 플레이스홀더 제거
|
||||
const placeholder = document.getElementById('chatPlaceholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
// 사용자 메시지 추가
|
||||
appendChatMessage('user', question);
|
||||
input.value = '';
|
||||
chatHistory.push({ role: 'user', content: question });
|
||||
|
||||
// 로딩 표시
|
||||
appendChatLoading();
|
||||
|
||||
try {
|
||||
const result = await AiAPI.askQuestion(question, projectId);
|
||||
removeChatLoading();
|
||||
|
||||
if (result.available === false) {
|
||||
appendChatMessage('ai', 'AI 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = result.answer || result.response || '답변을 생성할 수 없습니다.';
|
||||
const sources = result.sources || result.related_issues || [];
|
||||
appendChatMessage('ai', answer, sources);
|
||||
chatHistory.push({ role: 'ai', content: answer });
|
||||
|
||||
} catch (error) {
|
||||
removeChatLoading();
|
||||
appendChatMessage('ai', '오류가 발생했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function appendChatMessage(role, content, sources) {
|
||||
const container = document.getElementById('chatContainer');
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'} mb-3`;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = `chat-bubble ${role === 'user' ? 'chat-bubble-user' : 'chat-bubble-ai'}`;
|
||||
|
||||
// 내용 렌더링
|
||||
const contentDiv = document.createElement('div');
|
||||
if (role === 'ai' && typeof marked !== 'undefined') {
|
||||
contentDiv.className = 'text-sm prose prose-sm max-w-none';
|
||||
contentDiv.innerHTML = DOMPurify.sanitize(marked.parse(content));
|
||||
} else {
|
||||
contentDiv.className = 'text-sm whitespace-pre-line';
|
||||
contentDiv.textContent = content;
|
||||
}
|
||||
bubble.appendChild(contentDiv);
|
||||
|
||||
// AI 답변 참고 사례
|
||||
if (role === 'ai' && sources && sources.length > 0) {
|
||||
const sourcesDiv = document.createElement('div');
|
||||
sourcesDiv.className = 'mt-2 pt-2 border-t border-gray-200';
|
||||
|
||||
const sourcesTitle = document.createElement('p');
|
||||
sourcesTitle.className = 'text-xs text-gray-500 mb-1';
|
||||
sourcesTitle.textContent = '참고 사례:';
|
||||
sourcesDiv.appendChild(sourcesTitle);
|
||||
|
||||
sources.forEach(source => {
|
||||
const issueId = source.issue_id || source.id;
|
||||
const desc = source.description || source.title || `이슈 #${issueId}`;
|
||||
const similarity = source.similarity ? ` (${(source.similarity * 100).toFixed(0)}%)` : '';
|
||||
|
||||
const link = document.createElement('span');
|
||||
link.className = 'source-link text-xs block';
|
||||
link.textContent = `#${issueId} ${desc}${similarity}`;
|
||||
link.onclick = () => showAiIssueDetail(issueId);
|
||||
sourcesDiv.appendChild(link);
|
||||
});
|
||||
|
||||
bubble.appendChild(sourcesDiv);
|
||||
}
|
||||
|
||||
wrapper.appendChild(bubble);
|
||||
container.appendChild(wrapper);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function appendChatLoading() {
|
||||
const container = document.getElementById('chatContainer');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'flex justify-start mb-3';
|
||||
wrapper.id = 'chatLoadingBubble';
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="chat-bubble chat-bubble-ai">
|
||||
<div class="typing-indicator">
|
||||
<div class="typing-dot"></div>
|
||||
<div class="typing-dot"></div>
|
||||
<div class="typing-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(wrapper);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function removeChatLoading() {
|
||||
const el = document.getElementById('chatLoadingBubble');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
const container = document.getElementById('chatContainer');
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-gray-400 text-sm py-8" id="chatPlaceholder">
|
||||
<i class="fas fa-robot text-4xl mb-3 text-gray-300"></i>
|
||||
<p>부적합 관련 질문을 입력하세요.</p>
|
||||
<p class="text-xs mt-1">과거 사례를 분석하여 답변합니다.</p>
|
||||
</div>
|
||||
`;
|
||||
chatHistory = [];
|
||||
}
|
||||
|
||||
// ─── 시맨틱 검색 ────────────────────────────────────────
|
||||
async function executeSemanticSearch() {
|
||||
const query = document.getElementById('searchQuery').value.trim();
|
||||
if (!query) return;
|
||||
|
||||
const projectId = document.getElementById('searchProjectFilter').value || undefined;
|
||||
const category = document.getElementById('searchCategoryFilter').value || undefined;
|
||||
const limit = parseInt(document.getElementById('searchResultCount').value);
|
||||
|
||||
const loading = document.getElementById('searchLoading');
|
||||
const resultsDiv = document.getElementById('searchResults');
|
||||
loading.classList.remove('hidden');
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
try {
|
||||
const filters = {};
|
||||
if (projectId) filters.project_id = parseInt(projectId);
|
||||
if (category) filters.category = category;
|
||||
|
||||
const result = await AiAPI.searchSimilar(query, limit, filters);
|
||||
loading.classList.add('hidden');
|
||||
|
||||
if (!result.results || result.results.length === 0) {
|
||||
resultsDiv.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">검색 결과가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
result.results.forEach((item, idx) => {
|
||||
const issueId = item.issue_id || item.id;
|
||||
const desc = item.description || '';
|
||||
const similarity = item.similarity ? (item.similarity * 100).toFixed(1) : '-';
|
||||
const category = item.category || '';
|
||||
const status = item.status || item.review_status || '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'result-item border border-gray-200 rounded-lg p-3 flex items-start gap-3';
|
||||
card.onclick = () => showAiIssueDetail(issueId);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center text-xs font-bold text-indigo-600">
|
||||
${idx + 1}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-mono text-gray-400">#${issueId}</span>
|
||||
${category ? `<span class="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">${category}</span>` : ''}
|
||||
${status ? `<span class="text-xs bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">${status}</span>` : ''}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 truncate">${desc}</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<span class="text-sm font-bold text-indigo-600">${similarity}%</span>
|
||||
<p class="text-xs text-gray-400">유사도</p>
|
||||
</div>
|
||||
`;
|
||||
resultsDiv.appendChild(card);
|
||||
});
|
||||
} catch (error) {
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.innerHTML = `<p class="text-sm text-red-500 text-center py-4">검색 중 오류가 발생했습니다.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 패턴 분석 ──────────────────────────────────────────
|
||||
async function executePatternAnalysis() {
|
||||
const input = document.getElementById('patternInput').value.trim();
|
||||
if (!input) return;
|
||||
|
||||
const loading = document.getElementById('patternLoading');
|
||||
const resultsDiv = document.getElementById('patternResults');
|
||||
loading.classList.remove('hidden');
|
||||
resultsDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const result = await AiAPI.analyzePattern(input);
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.classList.remove('hidden');
|
||||
|
||||
if (result.available === false) {
|
||||
resultsDiv.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">AI 서비스에 연결할 수 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// 분석 결과
|
||||
const analysis = result.analysis || result.pattern || result.answer || '';
|
||||
if (analysis) {
|
||||
html += `
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-3">
|
||||
<h4 class="text-sm font-semibold text-green-800 mb-2">
|
||||
<i class="fas fa-chart-bar mr-1"></i>분석 결과
|
||||
</h4>
|
||||
<div class="text-sm text-gray-700 whitespace-pre-line">${analysis}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 관련 이슈
|
||||
const relatedIssues = result.related_issues || result.sources || [];
|
||||
if (relatedIssues.length > 0) {
|
||||
html += `
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-2">
|
||||
<i class="fas fa-link mr-1"></i>관련 이슈 (${relatedIssues.length}건)
|
||||
</h4>
|
||||
<div class="space-y-1">
|
||||
${relatedIssues.map(issue => {
|
||||
const id = issue.issue_id || issue.id;
|
||||
const desc = issue.description || '';
|
||||
return `<div class="result-item text-sm p-2 rounded border border-gray-100" onclick="showAiIssueDetail(${id})">
|
||||
<span class="font-mono text-gray-400 text-xs">#${id}</span>
|
||||
<span class="text-gray-700">${desc}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = html || '<p class="text-sm text-gray-500 text-center py-4">분석 결과가 없습니다.</p>';
|
||||
} catch (error) {
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.classList.remove('hidden');
|
||||
resultsDiv.innerHTML = `<p class="text-sm text-red-500 text-center py-4">분석 중 오류가 발생했습니다.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI 분류 ────────────────────────────────────────────
|
||||
async function executeClassification(useRAG) {
|
||||
const description = document.getElementById('classifyDescription').value.trim();
|
||||
if (!description) {
|
||||
alert('부적합 설명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const detailNotes = document.getElementById('classifyDetail').value.trim();
|
||||
const loading = document.getElementById('classifyLoading');
|
||||
const resultsDiv = document.getElementById('classifyResults');
|
||||
loading.classList.remove('hidden');
|
||||
resultsDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const result = useRAG
|
||||
? await AiAPI.classifyWithRAG(description, detailNotes)
|
||||
: await AiAPI.classifyIssue(description, detailNotes);
|
||||
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.classList.remove('hidden');
|
||||
|
||||
if (result.available === false) {
|
||||
resultsDiv.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">AI 서비스에 연결할 수 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const methodLabel = useRAG ? 'RAG 분류 (과거 사례 참고)' : '기본 분류';
|
||||
const methodColor = useRAG ? 'purple' : 'amber';
|
||||
|
||||
let html = `
|
||||
<div class="bg-${methodColor}-50 border border-${methodColor}-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-${methodColor}-800 mb-3">
|
||||
<i class="fas fa-${useRAG ? 'tags' : 'tag'} mr-1"></i>${methodLabel}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
`;
|
||||
|
||||
// 분류 결과 필드 표시
|
||||
const fields = [
|
||||
{ key: 'category', label: '카테고리' },
|
||||
{ key: 'discipline', label: '공종' },
|
||||
{ key: 'severity', label: '심각도' },
|
||||
{ key: 'root_cause', label: '근본 원인' },
|
||||
{ key: 'priority', label: '우선순위' },
|
||||
{ key: 'suggested_action', label: '권장 조치' }
|
||||
];
|
||||
|
||||
fields.forEach(field => {
|
||||
const val = result[field.key] || result.classification?.[field.key];
|
||||
if (val) {
|
||||
html += `
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">${field.label}</span>
|
||||
<p class="text-sm font-medium text-gray-800">${val}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// 신뢰도
|
||||
const confidence = result.confidence || result.classification?.confidence;
|
||||
if (confidence) {
|
||||
const pct = (typeof confidence === 'number' && confidence <= 1)
|
||||
? (confidence * 100).toFixed(0)
|
||||
: confidence;
|
||||
html += `
|
||||
<div class="mt-3 pt-3 border-t border-${methodColor}-200">
|
||||
<span class="text-xs text-gray-500">신뢰도</span>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-${methodColor}-500 h-2 rounded-full" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-${methodColor}-600">${pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// RAG 참고 사례
|
||||
const sources = result.sources || result.related_issues || result.similar_cases || [];
|
||||
if (sources.length > 0) {
|
||||
html += `
|
||||
<div class="mt-3 pt-3 border-t border-${methodColor}-200">
|
||||
<span class="text-xs text-gray-500">참고 사례</span>
|
||||
<div class="mt-1 space-y-1">
|
||||
${sources.map(s => {
|
||||
const id = s.issue_id || s.id;
|
||||
const desc = s.description || '';
|
||||
return `<div class="result-item text-xs p-1.5 rounded border border-gray-100" onclick="showAiIssueDetail(${id})">
|
||||
<span class="font-mono text-gray-400">#${id}</span> ${desc}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
resultsDiv.innerHTML = html;
|
||||
} catch (error) {
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.classList.remove('hidden');
|
||||
resultsDiv.innerHTML = `<p class="text-sm text-red-500 text-center py-4">분류 중 오류가 발생했습니다.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 이슈 상세 모달 ─────────────────────────────────────
|
||||
async function showAiIssueDetail(issueId) {
|
||||
const modal = document.getElementById('aiIssueModal');
|
||||
const title = document.getElementById('aiIssueModalTitle');
|
||||
const body = document.getElementById('aiIssueModalBody');
|
||||
|
||||
title.textContent = `이슈 #${issueId}`;
|
||||
body.innerHTML = '<div class="text-center py-8"><i class="fas fa-spinner fa-spin text-purple-500 text-xl"></i><p class="text-sm text-gray-500 mt-2">불러오는 중...</p></div>';
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/issues/${issueId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('이슈를 불러올 수 없습니다.');
|
||||
const issue = await response.json();
|
||||
|
||||
const statusMap = {
|
||||
'pending': '대기',
|
||||
'in_progress': '진행 중',
|
||||
'completed': '완료',
|
||||
'rejected': '반려'
|
||||
};
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">프로젝트</span>
|
||||
<p class="font-medium">${issue.project_name || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">설명</span>
|
||||
<p class="font-medium">${issue.description || '-'}</p>
|
||||
</div>
|
||||
${issue.detail_notes ? `<div>
|
||||
<span class="text-xs text-gray-500">상세 내용</span>
|
||||
<p class="whitespace-pre-line">${issue.detail_notes}</p>
|
||||
</div>` : ''}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">카테고리</span>
|
||||
<p>${issue.category || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">상태</span>
|
||||
<p>${statusMap[issue.review_status] || issue.review_status || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">위치</span>
|
||||
<p>${issue.location || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">담당자</span>
|
||||
<p>${issue.assigned_to_name || issue.assignee_name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
${issue.resolution ? `<div>
|
||||
<span class="text-xs text-gray-500">해결 방안</span>
|
||||
<p class="whitespace-pre-line">${issue.resolution}</p>
|
||||
</div>` : ''}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">등록일</span>
|
||||
<p>${issue.created_at ? new Date(issue.created_at).toLocaleDateString('ko-KR') : '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
body.innerHTML = `<p class="text-sm text-red-500 text-center py-4">이슈를 불러오는데 실패했습니다.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 엔트리 포인트
|
||||
function initializeAiAssistantApp() {
|
||||
console.log('AI 어시스턴트 스크립트 로드 완료');
|
||||
}
|
||||
initializeAiAssistantApp();
|
||||
895
system3-nonconformance/web/public/static/js/pages/issue-view.js
Normal file
895
system3-nonconformance/web/public/static/js/pages/issue-view.js
Normal file
@@ -0,0 +1,895 @@
|
||||
/**
|
||||
* 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('sso_user', JSON.stringify(user));
|
||||
|
||||
// 공통 헤더 초기화
|
||||
await window.commonHeader.init(user, 'issues_view');
|
||||
|
||||
// 헤더 초기화 후 부드러운 애니메이션 시작
|
||||
setTimeout(() => {
|
||||
animateHeaderAppearance();
|
||||
}, 100);
|
||||
|
||||
// 사용자 역할에 따른 페이지 제목 설정
|
||||
updatePageTitle(user);
|
||||
|
||||
// 페이지 접근 권한 체크 (부적합 조회 페이지)
|
||||
setTimeout(() => {
|
||||
if (!canAccessPage('issues_view')) {
|
||||
alert('부적합 조회 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('인증 실패:', error);
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 프로젝트 로드
|
||||
await loadProjects();
|
||||
|
||||
// 기본 날짜 설정 (이번 주)
|
||||
setDefaultDateRange();
|
||||
|
||||
// 기본값: 이번 주 데이터 로드
|
||||
await loadIssues();
|
||||
setDateRange('week');
|
||||
}
|
||||
|
||||
// showImageModal은 photo-modal.js에서 제공됨
|
||||
|
||||
// 기본 날짜 범위 설정
|
||||
function setDefaultDateRange() {
|
||||
const today = new Date();
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
|
||||
|
||||
// 날짜 입력 필드에 기본값 설정
|
||||
document.getElementById('startDateInput').value = formatDateForInput(weekStart);
|
||||
document.getElementById('endDateInput').value = formatDateForInput(today);
|
||||
}
|
||||
|
||||
// 날짜를 input[type="date"] 형식으로 포맷
|
||||
function formatDateForInput(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 날짜 필터 적용
|
||||
function applyDateFilter() {
|
||||
const startDate = document.getElementById('startDateInput').value;
|
||||
const endDate = document.getElementById('endDateInput').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작날짜와 끝날짜를 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
alert('시작날짜는 끝날짜보다 이전이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 필터 적용
|
||||
filterIssues();
|
||||
}
|
||||
|
||||
// 사용자 역할에 따른 페이지 제목 업데이트
|
||||
function updatePageTitle(user) {
|
||||
const titleElement = document.getElementById('pageTitle');
|
||||
const descriptionElement = document.getElementById('pageDescription');
|
||||
|
||||
if (user.role === 'admin') {
|
||||
titleElement.innerHTML = `
|
||||
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
|
||||
전체 부적합 조회
|
||||
`;
|
||||
descriptionElement.textContent = '모든 사용자가 등록한 부적합 사항을 관리할 수 있습니다';
|
||||
} else {
|
||||
titleElement.innerHTML = `
|
||||
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
|
||||
내 부적합 조회
|
||||
`;
|
||||
descriptionElement.textContent = '내가 등록한 부적합 사항을 확인할 수 있습니다';
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 로드 (API 기반)
|
||||
async function loadProjects() {
|
||||
try {
|
||||
// 모든 프로젝트 로드 (활성/비활성 모두 - 기존 데이터 조회를 위해)
|
||||
projects = await ProjectsAPI.getAll(false);
|
||||
const projectFilter = document.getElementById('projectFilter');
|
||||
|
||||
// 기존 옵션 제거 (전체 프로젝트 옵션 제외)
|
||||
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
|
||||
|
||||
// 모든 프로젝트 추가
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = `${project.job_no} / ${project.project_name}${!project.is_active ? ' (비활성)' : ''}`;
|
||||
projectFilter.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 필터링
|
||||
// 검토 상태 확인 함수
|
||||
function isReviewCompleted(issue) {
|
||||
return issue.status === 'complete' && issue.work_hours && issue.work_hours > 0;
|
||||
}
|
||||
|
||||
// 날짜 필터링 함수
|
||||
function filterByDate(issues, dateFilter) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (dateFilter) {
|
||||
case 'today':
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.report_date);
|
||||
return issueDate >= today;
|
||||
});
|
||||
case 'week':
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay());
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.report_date);
|
||||
return issueDate >= weekStart;
|
||||
});
|
||||
case 'month':
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.report_date);
|
||||
return issueDate >= monthStart;
|
||||
});
|
||||
default:
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 범위별 필터링 함수
|
||||
function filterByDateRange(issues, range) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.created_at);
|
||||
const issueDay = new Date(issueDate.getFullYear(), issueDate.getMonth(), issueDate.getDate());
|
||||
return issueDay.getTime() === today.getTime();
|
||||
});
|
||||
|
||||
case 'week':
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay());
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.created_at);
|
||||
return issueDate >= weekStart && issueDate <= weekEnd;
|
||||
});
|
||||
|
||||
case 'month':
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
monthEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
return issues.filter(issue => {
|
||||
const issueDate = new Date(issue.created_at);
|
||||
return issueDate >= monthStart && issueDate <= monthEnd;
|
||||
});
|
||||
|
||||
default:
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
|
||||
function filterIssues() {
|
||||
// 필터 값 가져오기
|
||||
const selectedProjectId = document.getElementById('projectFilter').value;
|
||||
const reviewStatusFilter = document.getElementById('reviewStatusFilter').value;
|
||||
|
||||
let filteredIssues = [...issues];
|
||||
|
||||
// 프로젝트 필터 적용
|
||||
if (selectedProjectId) {
|
||||
filteredIssues = filteredIssues.filter(issue => {
|
||||
const issueProjectId = issue.project_id || issue.projectId;
|
||||
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// 워크플로우 상태 필터 적용
|
||||
if (reviewStatusFilter) {
|
||||
filteredIssues = filteredIssues.filter(issue => {
|
||||
// 새로운 워크플로우 시스템 사용
|
||||
if (issue.review_status) {
|
||||
return issue.review_status === reviewStatusFilter;
|
||||
}
|
||||
// 기존 데이터 호환성을 위한 폴백
|
||||
else {
|
||||
const isCompleted = isReviewCompleted(issue);
|
||||
if (reviewStatusFilter === 'pending_review') return !isCompleted;
|
||||
if (reviewStatusFilter === 'completed') return isCompleted;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 범위 필터 적용 (입력 필드에서 선택된 범위)
|
||||
const startDateInput = document.getElementById('startDateInput').value;
|
||||
const endDateInput = document.getElementById('endDateInput').value;
|
||||
|
||||
if (startDateInput && endDateInput) {
|
||||
filteredIssues = filteredIssues.filter(issue => {
|
||||
const issueDate = new Date(issue.report_date);
|
||||
const startOfDay = new Date(startDateInput);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(endDateInput);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
return issueDate >= startOfDay && issueDate <= endOfDay;
|
||||
});
|
||||
}
|
||||
|
||||
// 전역 변수에 필터링된 결과 저장
|
||||
window.filteredIssues = filteredIssues;
|
||||
|
||||
displayResults();
|
||||
}
|
||||
|
||||
// 프로젝트 정보 표시용 함수
|
||||
function getProjectInfo(projectId) {
|
||||
if (!projectId) {
|
||||
return '<span class="text-gray-500">프로젝트 미지정</span>';
|
||||
}
|
||||
|
||||
// 전역 projects 배열에서 찾기
|
||||
const project = projects.find(p => p.id == projectId);
|
||||
if (project) {
|
||||
return `${project.job_no} / ${project.project_name}`;
|
||||
}
|
||||
|
||||
return `<span class="text-red-500">프로젝트 ID: ${projectId} (정보 없음)</span>`;
|
||||
}
|
||||
|
||||
// 날짜 범위 설정 및 자동 조회
|
||||
function setDateRange(range) {
|
||||
currentRange = range;
|
||||
|
||||
const today = new Date();
|
||||
let startDate, endDate;
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
startDate = new Date(today);
|
||||
endDate = new Date(today);
|
||||
break;
|
||||
case 'week':
|
||||
startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
|
||||
endDate = new Date(today);
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
|
||||
endDate = new Date(today);
|
||||
break;
|
||||
case 'all':
|
||||
startDate = new Date(2020, 0, 1); // 충분히 과거 날짜
|
||||
endDate = new Date(today);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// 날짜 입력 필드 업데이트
|
||||
document.getElementById('startDateInput').value = formatDateForInput(startDate);
|
||||
document.getElementById('endDateInput').value = formatDateForInput(endDate);
|
||||
|
||||
// 필터 적용
|
||||
filterIssues();
|
||||
}
|
||||
|
||||
// 부적합 사항 로드 (자신이 올린 내용만)
|
||||
async function loadIssues() {
|
||||
const container = document.getElementById('issueResults');
|
||||
container.innerHTML = `
|
||||
<div class="text-gray-500 text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
// 모든 이슈 가져오기
|
||||
const allIssues = await IssuesAPI.getAll();
|
||||
|
||||
// 자신이 올린 이슈만 필터링
|
||||
issues = allIssues
|
||||
.filter(issue => issue.reporter_id === currentUser.id)
|
||||
.sort((a, b) => new Date(b.report_date) - new Date(a.report_date));
|
||||
|
||||
// 결과 표시
|
||||
filterIssues();
|
||||
|
||||
} catch (error) {
|
||||
console.error('부적합 사항 로드 실패:', error);
|
||||
container.innerHTML = `
|
||||
<div class="text-red-500 text-center py-8">
|
||||
<i class="fas fa-exclamation-triangle text-3xl mb-3"></i>
|
||||
<p>데이터를 불러오는데 실패했습니다.</p>
|
||||
<button onclick="loadIssues()" class="mt-3 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 표시 (시간순 나열)
|
||||
function displayResults() {
|
||||
const container = document.getElementById('issueResults');
|
||||
|
||||
// 필터링된 결과 사용 (filterIssues에서 설정됨)
|
||||
const filteredIssues = window.filteredIssues || issues;
|
||||
|
||||
if (filteredIssues.length === 0) {
|
||||
const emptyMessage = currentUser.role === 'admin'
|
||||
? '조건에 맞는 부적합 사항이 없습니다.'
|
||||
: '아직 등록한 부적합 사항이 없습니다.<br><small class="text-sm">부적합 등록 페이지에서 새로운 부적합을 등록해보세요.</small>';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-gray-500 text-center py-12">
|
||||
<i class="fas fa-inbox text-4xl mb-4 text-gray-400"></i>
|
||||
<p class="text-lg mb-2">${emptyMessage}</p>
|
||||
${currentUser.role !== 'admin' ? `
|
||||
<div class="mt-4">
|
||||
<a href="/index.html" class="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
부적합 등록하기
|
||||
</a>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 워크플로우 상태별로 분류 및 정렬
|
||||
const groupedIssues = {
|
||||
pending_review: filteredIssues.filter(issue =>
|
||||
issue.review_status === 'pending_review' || (!issue.review_status && !isReviewCompleted(issue))
|
||||
),
|
||||
in_progress: filteredIssues.filter(issue => issue.review_status === 'in_progress'),
|
||||
completed: filteredIssues.filter(issue =>
|
||||
issue.review_status === 'completed' || (!issue.review_status && isReviewCompleted(issue))
|
||||
),
|
||||
disposed: filteredIssues.filter(issue => issue.review_status === 'disposed')
|
||||
};
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// 각 상태별로 표시
|
||||
const statusConfig = [
|
||||
{ key: 'pending_review', title: '수신함 (검토 대기)', icon: 'fas fa-inbox', color: 'text-orange-700' },
|
||||
{ key: 'in_progress', title: '관리함 (진행 중)', icon: 'fas fa-cog', color: 'text-blue-700' },
|
||||
{ key: 'completed', title: '관리함 (완료됨)', icon: 'fas fa-check-circle', color: 'text-green-700' },
|
||||
{ key: 'disposed', title: '폐기함 (폐기됨)', icon: 'fas fa-trash', color: 'text-gray-700' }
|
||||
];
|
||||
|
||||
statusConfig.forEach((config, index) => {
|
||||
const issues = groupedIssues[config.key];
|
||||
if (issues.length > 0) {
|
||||
const header = document.createElement('div');
|
||||
header.className = index > 0 ? 'mb-4 mt-8' : 'mb-4';
|
||||
header.innerHTML = `
|
||||
<h3 class="text-md font-semibold ${config.color} flex items-center">
|
||||
<i class="${config.icon} mr-2"></i>${config.title} (${issues.length}건)
|
||||
</h3>
|
||||
`;
|
||||
container.appendChild(header);
|
||||
|
||||
issues.forEach(issue => {
|
||||
container.appendChild(createIssueCard(issue, config.key === 'completed'));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 워크플로우 상태 표시 함수
|
||||
function getWorkflowStatusBadge(issue) {
|
||||
const status = issue.review_status || (isReviewCompleted(issue) ? 'completed' : 'pending_review');
|
||||
|
||||
const statusConfig = {
|
||||
'pending_review': { text: '검토 대기', class: 'bg-orange-100 text-orange-700', icon: 'fas fa-inbox' },
|
||||
'in_progress': { text: '진행 중', class: 'bg-blue-100 text-blue-700', icon: 'fas fa-cog' },
|
||||
'completed': { text: '완료됨', class: 'bg-green-100 text-green-700', icon: 'fas fa-check-circle' },
|
||||
'disposed': { text: '폐기됨', class: 'bg-gray-100 text-gray-700', icon: 'fas fa-trash' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig['pending_review'];
|
||||
return `<span class="px-2 py-1 rounded-full text-xs font-medium ${config.class}">
|
||||
<i class="${config.icon} mr-1"></i>${config.text}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// 부적합 사항 카드 생성 함수 (조회용)
|
||||
function createIssueCard(issue, isCompleted) {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
|
||||
const categoryColors = {
|
||||
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
design_error: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
incoming_defect: 'bg-red-100 text-red-700 border-red-300',
|
||||
inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300'
|
||||
};
|
||||
|
||||
const div = document.createElement('div');
|
||||
// 검토 완료 상태에 따른 스타일링
|
||||
const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4';
|
||||
const statusClasses = isCompleted
|
||||
? 'bg-gray-100 opacity-75'
|
||||
: 'bg-gray-50 hover:bg-gray-100';
|
||||
const borderColor = categoryColors[issue.category]?.split(' ')[2] || 'border-gray-300';
|
||||
div.className = `${baseClasses} ${statusClasses} ${borderColor}`;
|
||||
|
||||
const dateStr = DateUtils.formatKST(issue.report_date, true);
|
||||
const relativeTime = DateUtils.getRelativeTime(issue.report_date);
|
||||
const projectInfo = getProjectInfo(issue.project_id || issue.projectId);
|
||||
|
||||
// 수정/삭제 권한 확인 (본인이 등록한 부적합만)
|
||||
const canEdit = issue.reporter_id === currentUser.id;
|
||||
const canDelete = issue.reporter_id === currentUser.id || currentUser.role === 'admin';
|
||||
|
||||
div.innerHTML = `
|
||||
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
|
||||
<div class="flex justify-between items-start p-2 pb-0">
|
||||
<div class="flex items-center gap-2">
|
||||
${getWorkflowStatusBadge(issue)}
|
||||
</div>
|
||||
<div class="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
|
||||
<i class="fas fa-folder-open mr-1"></i>${projectInfo}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기존 내용 -->
|
||||
<div class="flex gap-3 p-3 pt-1">
|
||||
<!-- 사진들 -->
|
||||
<div class="flex gap-1 flex-shrink-0 flex-wrap max-w-md">
|
||||
${(() => {
|
||||
const photos = [
|
||||
issue.photo_path,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
].filter(p => p);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return `
|
||||
<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400"></i>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return photos.map(path => `
|
||||
<img src="${path}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${path}')">
|
||||
`).join('');
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium ${categoryColors[issue.category] || 'bg-gray-100 text-gray-700'}">
|
||||
${categoryNames[issue.category] || issue.category}
|
||||
</span>
|
||||
${issue.work_hours ?
|
||||
`<span class="text-sm text-green-600 font-medium">
|
||||
<i class="fas fa-clock mr-1"></i>${issue.work_hours}시간
|
||||
</span>` :
|
||||
'<span class="text-sm text-gray-400">시간 미입력</span>'
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="text-gray-800 mb-2 line-clamp-2">${issue.description}</p>
|
||||
|
||||
${issue.location_info ? `<div class="flex items-center text-sm text-gray-600 mb-2"><i class="fas fa-map-marker-alt mr-1 text-red-500"></i>${issue.location_info}</div>` : ''}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span>
|
||||
<span><i class="fas fa-calendar mr-1"></i>${dateStr}</span>
|
||||
<span class="text-xs text-gray-400">${relativeTime}</span>
|
||||
</div>
|
||||
|
||||
<!-- 수정/삭제 버튼 -->
|
||||
${(canEdit || canDelete) ? `
|
||||
<div class="flex gap-2">
|
||||
${canEdit ? `
|
||||
<button onclick='showEditModal(${JSON.stringify(issue).replace(/'/g, "'")})' class="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-edit mr-1"></i>수정
|
||||
</button>
|
||||
` : ''}
|
||||
${canDelete ? `
|
||||
<button onclick="confirmDelete(${issue.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600 transition-colors">
|
||||
<i class="fas fa-trash mr-1"></i>삭제
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
// 관리 버튼 클릭 처리
|
||||
function handleAdminClick() {
|
||||
if (currentUser.role === 'admin') {
|
||||
// 관리자: 사용자 관리 페이지로 이동
|
||||
window.location.href = 'admin.html';
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀번호 변경 모달 표시
|
||||
function showPasswordChangeModal() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">비밀번호 변경</h3>
|
||||
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="passwordChangeForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
|
||||
<input type="password" id="currentPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
|
||||
<input type="password" id="newPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required minlength="6">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
|
||||
<input type="password" id="confirmPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||
변경
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// 폼 제출 이벤트 처리
|
||||
document.getElementById('passwordChangeForm').addEventListener('submit', handlePasswordChange);
|
||||
}
|
||||
|
||||
// 비밀번호 변경 처리
|
||||
async function handlePasswordChange(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
// 새 비밀번호 확인
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 비밀번호 확인 (localStorage 기반)
|
||||
let users = JSON.parse(localStorage.getItem('work-report-users') || '[]');
|
||||
|
||||
// 임시 비밀번호 생성 (클라이언트에 하드코딩하지 않음)
|
||||
function generateTempPassword() {
|
||||
const arr = new Uint8Array(12);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, b => b.toString(36).padStart(2, '0')).join('').slice(0, 16);
|
||||
}
|
||||
|
||||
// 기본 사용자가 없으면 생성
|
||||
if (users.length === 0) {
|
||||
users = [
|
||||
{
|
||||
username: 'hyungi',
|
||||
full_name: '관리자',
|
||||
password: generateTempPassword(),
|
||||
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: generateTempPassword(),
|
||||
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('sso_user', JSON.stringify(currentUser));
|
||||
|
||||
alert('비밀번호가 성공적으로 변경되었습니다.');
|
||||
document.querySelector('.fixed').remove(); // 모달 닫기
|
||||
|
||||
} catch (error) {
|
||||
alert('비밀번호 변경에 실패했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃 함수
|
||||
function logout() {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// 수정 모달 표시
|
||||
function showEditModal(issue) {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">부적합 수정</h3>
|
||||
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="editIssueForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
|
||||
<select id="editCategory" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
|
||||
<option value="design_error" ${issue.category === 'design_error' ? 'selected' : ''}>설계미스</option>
|
||||
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
|
||||
<option value="inspection_miss" ${issue.category === 'inspection_miss' ? 'selected' : ''}>검사미스</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
|
||||
<select id="editProject" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||
${projects.map(p => `
|
||||
<option value="${p.id}" ${p.id === issue.project_id ? 'selected' : ''}>
|
||||
${p.job_no} / ${p.project_name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">내용</label>
|
||||
<textarea id="editDescription" class="w-full px-3 py-2 border border-gray-300 rounded-lg" rows="4" required>${issue.description || ''}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// 폼 제출 이벤트 처리
|
||||
document.getElementById('editIssueForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const updateData = {
|
||||
category: document.getElementById('editCategory').value,
|
||||
description: document.getElementById('editDescription').value,
|
||||
project_id: parseInt(document.getElementById('editProject').value)
|
||||
};
|
||||
|
||||
try {
|
||||
await IssuesAPI.update(issue.id, updateData);
|
||||
alert('수정되었습니다.');
|
||||
modal.remove();
|
||||
// 목록 새로고침
|
||||
await loadIssues();
|
||||
} catch (error) {
|
||||
console.error('수정 실패:', error);
|
||||
alert('수정에 실패했습니다: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
function confirmDelete(issueId) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
||||
<div class="text-center mb-4">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">부적합 삭제</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
이 부적합 사항을 삭제하시겠습니까?<br>
|
||||
삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button onclick="this.closest('.fixed').remove()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button onclick="handleDelete(${issueId})"
|
||||
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 삭제 처리
|
||||
async function handleDelete(issueId) {
|
||||
try {
|
||||
await IssuesAPI.delete(issueId);
|
||||
alert('삭제되었습니다.');
|
||||
|
||||
// 모달 닫기
|
||||
const modal = document.querySelector('.fixed');
|
||||
if (modal) modal.remove();
|
||||
|
||||
// 목록 새로고침
|
||||
await loadIssues();
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
alert('삭제에 실패했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// API 스크립트 동적 로딩
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/api.js?v=20260213';
|
||||
script.onload = function() {
|
||||
console.log('API 스크립트 로드 완료 (issue-view.html)');
|
||||
// API 로드 후 초기화 시작
|
||||
initializeIssueView();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error('API 스크립트 로드 실패');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
@@ -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('sso_user', JSON.stringify(user));
|
||||
|
||||
// 공통 헤더 초기화
|
||||
await window.commonHeader.init(user, 'issues_archive');
|
||||
|
||||
// 페이지 접근 권한 체크
|
||||
setTimeout(() => {
|
||||
if (!canAccessPage('issues_archive')) {
|
||||
alert('폐기함 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 데이터 로드
|
||||
await loadProjects();
|
||||
await loadArchivedIssues();
|
||||
|
||||
} catch (error) {
|
||||
console.error('인증 실패:', error);
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
projects = await response.json();
|
||||
updateProjectFilter();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 보관된 부적합 로드
|
||||
async function loadArchivedIssues() {
|
||||
try {
|
||||
let endpoint = '/api/issues/';
|
||||
|
||||
// 관리자인 경우 전체 부적합 조회 API 사용
|
||||
if (currentUser.role === 'admin') {
|
||||
endpoint = '/api/issues/admin/all';
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const allIssues = await response.json();
|
||||
// 폐기된 부적합만 필터링 (폐기함 전용)
|
||||
issues = allIssues.filter(issue =>
|
||||
issue.review_status === 'disposed'
|
||||
);
|
||||
|
||||
filterIssues();
|
||||
updateStatistics();
|
||||
renderCharts();
|
||||
} else {
|
||||
throw new Error('부적합 목록을 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('부적합 로드 실패:', error);
|
||||
alert('부적합 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 필터링 및 표시
|
||||
function filterIssues() {
|
||||
const projectFilter = document.getElementById('projectFilter').value;
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
const periodFilter = document.getElementById('periodFilter').value;
|
||||
const categoryFilter = document.getElementById('categoryFilter').value;
|
||||
const searchInput = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
filteredIssues = issues.filter(issue => {
|
||||
if (projectFilter && issue.project_id != projectFilter) return false;
|
||||
if (statusFilter && issue.status !== statusFilter) return false;
|
||||
if (categoryFilter && issue.category !== categoryFilter) return false;
|
||||
|
||||
// 기간 필터
|
||||
if (periodFilter) {
|
||||
const issueDate = new Date(issue.updated_at || issue.created_at);
|
||||
const now = new Date();
|
||||
|
||||
switch (periodFilter) {
|
||||
case 'week':
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
if (issueDate < weekAgo) return false;
|
||||
break;
|
||||
case 'month':
|
||||
const monthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
if (issueDate < monthAgo) return false;
|
||||
break;
|
||||
case 'quarter':
|
||||
const quarterAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
if (issueDate < quarterAgo) return false;
|
||||
break;
|
||||
case 'year':
|
||||
const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
if (issueDate < yearAgo) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
const searchText = `${issue.description} ${issue.reporter?.username || ''}`.toLowerCase();
|
||||
if (!searchText.includes(searchInput)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
sortIssues();
|
||||
displayIssues();
|
||||
}
|
||||
|
||||
function sortIssues() {
|
||||
const sortOrder = document.getElementById('sortOrder').value;
|
||||
|
||||
filteredIssues.sort((a, b) => {
|
||||
switch (sortOrder) {
|
||||
case 'newest':
|
||||
return new Date(b.report_date) - new Date(a.report_date);
|
||||
case 'oldest':
|
||||
return new Date(a.report_date) - new Date(b.report_date);
|
||||
case 'completed':
|
||||
return new Date(b.disposed_at || b.report_date) - new Date(a.disposed_at || a.report_date);
|
||||
case 'category':
|
||||
return (a.category || '').localeCompare(b.category || '');
|
||||
default:
|
||||
return new Date(b.report_date) - new Date(a.report_date);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function displayIssues() {
|
||||
const container = document.getElementById('issuesList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (filteredIssues.length === 0) {
|
||||
container.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
container.innerHTML = filteredIssues.map(issue => {
|
||||
const project = projects.find(p => p.id === issue.project_id);
|
||||
|
||||
// 폐기함은 폐기된 것만 표시
|
||||
const completedDate = issue.disposed_at ? new Date(issue.disposed_at).toLocaleDateString('ko-KR') : 'Invalid Date';
|
||||
const statusText = '폐기';
|
||||
const cardClass = 'archived-card';
|
||||
|
||||
return `
|
||||
<div class="issue-card p-6 ${cardClass} cursor-pointer"
|
||||
onclick="viewArchivedIssue(${issue.id})">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="badge badge-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</span>
|
||||
${project ? `<span class="text-sm text-gray-500">${project.project_name}</span>` : ''}
|
||||
<span class="text-sm text-gray-400">${completedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">${issue.description}</h3>
|
||||
|
||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span>
|
||||
${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''}
|
||||
<span><i class="fas fa-clock mr-1"></i>${statusText}: ${completedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<i class="fas fa-${getStatusIcon(issue.status)} text-2xl ${getStatusColor(issue.status)}"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStatistics() {
|
||||
const completed = issues.filter(issue => issue.status === 'completed').length;
|
||||
const archived = issues.filter(issue => issue.status === 'archived').length;
|
||||
const cancelled = issues.filter(issue => issue.status === 'cancelled').length;
|
||||
|
||||
const thisMonth = issues.filter(issue => {
|
||||
const issueDate = new Date(issue.updated_at || issue.created_at);
|
||||
const now = new Date();
|
||||
return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
|
||||
document.getElementById('completedCount').textContent = completed;
|
||||
document.getElementById('archivedCount').textContent = archived;
|
||||
document.getElementById('cancelledCount').textContent = cancelled;
|
||||
document.getElementById('thisMonthCount').textContent = thisMonth;
|
||||
}
|
||||
|
||||
// 차트 렌더링 (간단한 텍스트 기반)
|
||||
function renderCharts() {
|
||||
renderMonthlyChart();
|
||||
renderCategoryChart();
|
||||
}
|
||||
|
||||
function renderMonthlyChart() {
|
||||
const canvas = document.getElementById('monthlyChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '16px Inter';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('월별 완료 현황 차트', canvas.width / 2, canvas.height / 2);
|
||||
ctx.font = '12px Inter';
|
||||
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
|
||||
}
|
||||
|
||||
function renderCategoryChart() {
|
||||
const canvas = document.getElementById('categoryChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '16px Inter';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('카테고리별 분포 차트', canvas.width / 2, canvas.height / 2);
|
||||
ctx.font = '12px Inter';
|
||||
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
|
||||
}
|
||||
|
||||
// 기타 함수들
|
||||
function generateReport() {
|
||||
alert('통계 보고서를 생성합니다.');
|
||||
}
|
||||
|
||||
function cleanupArchive() {
|
||||
if (confirm('오래된 보관 데이터를 정리하시겠습니까?')) {
|
||||
alert('데이터 정리가 완료되었습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function viewArchivedIssue(issueId) {
|
||||
window.location.href = `/issue-view.html#detail-${issueId}`;
|
||||
}
|
||||
|
||||
// 유틸리티 함수들
|
||||
function updateProjectFilter() {
|
||||
const projectFilter = document.getElementById('projectFilter');
|
||||
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
|
||||
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = project.project_name;
|
||||
projectFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 전용 유틸리티 (shared에 없는 것들)
|
||||
function getStatusIcon(status) {
|
||||
const iconMap = {
|
||||
'completed': 'check-circle',
|
||||
'archived': 'archive',
|
||||
'cancelled': 'times-circle'
|
||||
};
|
||||
return iconMap[status] || 'archive';
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colorMap = {
|
||||
'completed': 'text-green-500',
|
||||
'archived': 'text-gray-500',
|
||||
'cancelled': 'text-red-500'
|
||||
};
|
||||
return colorMap[status] || 'text-gray-500';
|
||||
}
|
||||
|
||||
// API 스크립트 동적 로딩
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/api.js?v=20260213';
|
||||
script.onload = function() {
|
||||
console.log('API 스크립트 로드 완료 (issues-archive.html)');
|
||||
initializeArchive();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error('API 스크립트 로드 실패');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,953 @@
|
||||
/**
|
||||
* issues-inbox.js — 수신함 페이지 스크립트
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
let issues = [];
|
||||
let projects = [];
|
||||
let filteredIssues = [];
|
||||
|
||||
// 한국 시간(KST) 유틸리티 함수
|
||||
// DB에 KST로 저장된 naive datetime을 그대로 표시
|
||||
function formatKSTDate(date) {
|
||||
return new Date(date).toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
}
|
||||
|
||||
function formatKSTTime(date) {
|
||||
return new Date(date).toLocaleTimeString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function getKSTToday() {
|
||||
const now = new Date();
|
||||
const kst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
|
||||
return new Date(kst.getFullYear(), kst.getMonth(), kst.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('sso_user', 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('sso_user') || '{}');
|
||||
if (user.id) {
|
||||
await window.commonHeader.init(user, 'issues_inbox');
|
||||
// 에러 상황에서도 애니메이션 적용
|
||||
setTimeout(() => {
|
||||
animateHeaderAppearance();
|
||||
}, 100);
|
||||
}
|
||||
} catch (headerError) {
|
||||
console.error('공통 헤더 초기화 실패:', headerError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
projects = await response.json();
|
||||
updateProjectFilter();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 필터 업데이트
|
||||
function updateProjectFilter() {
|
||||
const projectFilter = document.getElementById('projectFilter');
|
||||
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
|
||||
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = project.project_name;
|
||||
projectFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// 수신함 부적합 목록 로드 (실제 API 연동)
|
||||
async function loadIssues() {
|
||||
showLoading(true);
|
||||
try {
|
||||
const projectId = document.getElementById('projectFilter').value;
|
||||
let url = '/api/inbox/';
|
||||
|
||||
// 프로젝트 필터 적용
|
||||
if (projectId) {
|
||||
url += `?project_id=${projectId}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
issues = await response.json();
|
||||
|
||||
|
||||
filterIssues();
|
||||
await loadStatistics();
|
||||
} else {
|
||||
throw new Error('수신함 목록을 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('수신함 로드 실패:', error);
|
||||
showError('수신함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 신고 필터링
|
||||
function filterIssues() {
|
||||
const projectFilter = document.getElementById('projectFilter').value;
|
||||
|
||||
filteredIssues = issues.filter(issue => {
|
||||
// 프로젝트 필터
|
||||
if (projectFilter && issue.project_id != projectFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
sortIssues();
|
||||
displayIssues();
|
||||
}
|
||||
|
||||
// 신고 정렬
|
||||
function sortIssues() {
|
||||
const sortOrder = document.getElementById('sortOrder').value;
|
||||
|
||||
filteredIssues.sort((a, b) => {
|
||||
switch (sortOrder) {
|
||||
case 'newest':
|
||||
return new Date(b.report_date) - new Date(a.report_date);
|
||||
case 'oldest':
|
||||
return new Date(a.report_date) - new Date(b.report_date);
|
||||
case 'priority':
|
||||
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
||||
return (priorityOrder[b.priority] || 1) - (priorityOrder[a.priority] || 1);
|
||||
default:
|
||||
return new Date(b.report_date) - new Date(a.report_date);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 부적합 목록 표시
|
||||
function displayIssues() {
|
||||
const container = document.getElementById('issuesList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (filteredIssues.length === 0) {
|
||||
container.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
container.innerHTML = filteredIssues.map(issue => {
|
||||
const project = projects.find(p => p.id === issue.project_id);
|
||||
const reportDate = new Date(issue.report_date);
|
||||
const createdDate = formatKSTDate(reportDate);
|
||||
const createdTime = formatKSTTime(reportDate);
|
||||
const timeAgo = getTimeAgo(reportDate);
|
||||
|
||||
// 사진 정보 처리
|
||||
const photoCount = [issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean).length;
|
||||
const photoInfo = photoCount > 0 ? `사진 ${photoCount}장` : '사진 없음';
|
||||
|
||||
return `
|
||||
<div class="issue-card p-6 hover:bg-gray-50 border-l-4 border-blue-500"
|
||||
data-issue-id="${issue.id}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<!-- 상단 정보 -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="badge badge-new">검토 대기</span>
|
||||
${project ? `<span class="text-sm font-medium text-blue-600">${project.project_name}</span>` : '<span class="text-sm text-gray-400">프로젝트 미지정</span>'}
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">ID: ${issue.id}</span>
|
||||
</div>
|
||||
|
||||
<!-- 제목 -->
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3 cursor-pointer hover:text-blue-600 transition-colors" onclick="viewIssueDetail(${issue.id})">${issue.final_description || issue.description}</h3>
|
||||
|
||||
<!-- 상세 정보 그리드 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4 text-sm">
|
||||
<div class="flex items-center text-gray-600">
|
||||
<i class="fas fa-user mr-2 text-blue-500"></i>
|
||||
<span class="font-medium">${issue.reporter?.username || '알 수 없음'}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-gray-600">
|
||||
<i class="fas fa-tag mr-2 text-green-500"></i>
|
||||
<span>${getCategoryText(issue.category || issue.final_category)}</span>
|
||||
</div>
|
||||
${issue.location_info ? `<div class="flex items-center text-gray-600">
|
||||
<i class="fas fa-map-marker-alt mr-2 text-red-500"></i>
|
||||
<span>${issue.location_info}</span>
|
||||
</div>` : ''}
|
||||
<div class="flex items-center text-gray-600">
|
||||
<i class="fas fa-camera mr-2 text-purple-500"></i>
|
||||
<span class="${photoCount > 0 ? 'text-purple-600 font-medium' : ''}">${photoInfo}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-gray-600">
|
||||
<i class="fas fa-clock mr-2 text-orange-500"></i>
|
||||
<span class="font-medium">${timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 업로드 시간 정보 -->
|
||||
<div class="bg-gray-50 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center text-gray-600">
|
||||
<i class="fas fa-calendar-alt mr-2"></i>
|
||||
<span>업로드: <strong>${createdDate} ${createdTime}</strong></span>
|
||||
</div>
|
||||
${issue.work_hours > 0 ? `<div class="flex items-center text-gray-600">
|
||||
<i class="fas fa-hourglass-half mr-2"></i>
|
||||
<span>공수: <strong>${issue.work_hours}시간</strong></span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${issue.detail_notes ? `<div class="mt-2 text-sm text-gray-600">
|
||||
<i class="fas fa-sticky-note mr-2"></i>
|
||||
<span class="italic">"${issue.detail_notes}"</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- 사진 미리보기 -->
|
||||
${photoCount > 0 ? `
|
||||
<div class="photo-gallery">
|
||||
${[issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5]
|
||||
.filter(Boolean)
|
||||
.map((path, idx) => `<img src="${path}" class="photo-preview" onclick="openPhotoModal('${path}')" alt="첨부 사진 ${idx + 1}">`)
|
||||
.join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 워크플로우 액션 버튼들 -->
|
||||
<div class="flex items-center space-x-2 mt-3">
|
||||
<button onclick="openDisposeModal(${issue.id})"
|
||||
class="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors">
|
||||
<i class="fas fa-trash mr-1"></i>폐기
|
||||
</button>
|
||||
<button onclick="openReviewModal(${issue.id})"
|
||||
class="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-edit mr-1"></i>검토
|
||||
</button>
|
||||
<button onclick="openStatusModal(${issue.id})"
|
||||
class="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 transition-colors">
|
||||
<i class="fas fa-check mr-1"></i>확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 통계 로드 (새로운 기준)
|
||||
async function loadStatistics() {
|
||||
try {
|
||||
// 현재 수신함 이슈들을 기반으로 통계 계산 (KST 기준)
|
||||
const todayStart = getKSTToday();
|
||||
|
||||
// 금일 신규: 오늘 올라온 목록 숫자 (확인된 것 포함) - KST 기준
|
||||
const todayNewCount = issues.filter(issue => {
|
||||
const reportDate = 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 = new Date(issue.report_date);
|
||||
const reportDateOnly = new Date(reportDate.getFullYear(), reportDate.getMonth(), reportDate.getDate());
|
||||
return reportDateOnly < todayStart;
|
||||
}).length;
|
||||
|
||||
// 통계 업데이트
|
||||
document.getElementById('todayNewCount').textContent = todayNewCount;
|
||||
document.getElementById('todayProcessedCount').textContent = todayProcessedCount;
|
||||
document.getElementById('unresolvedCount').textContent = unresolvedCount;
|
||||
|
||||
} catch (error) {
|
||||
console.error('통계 로드 오류:', error);
|
||||
// 오류 시 기본값 설정
|
||||
document.getElementById('todayNewCount').textContent = '0';
|
||||
document.getElementById('todayProcessedCount').textContent = '0';
|
||||
document.getElementById('unresolvedCount').textContent = '0';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 새로고침
|
||||
function refreshInbox() {
|
||||
loadIssues();
|
||||
}
|
||||
|
||||
// 신고 상세 보기
|
||||
function viewIssueDetail(issueId) {
|
||||
window.location.href = `/issue-view.html#detail-${issueId}`;
|
||||
}
|
||||
|
||||
// openPhotoModal, closePhotoModal, handleEscKey는 photo-modal.js에서 제공됨
|
||||
|
||||
// ===== 워크플로우 모달 관련 함수들 =====
|
||||
let currentIssueId = null;
|
||||
|
||||
// 폐기 모달 열기
|
||||
function openDisposeModal(issueId) {
|
||||
currentIssueId = issueId;
|
||||
document.getElementById('disposalReason').value = 'duplicate';
|
||||
document.getElementById('customReason').value = '';
|
||||
document.getElementById('customReasonDiv').classList.add('hidden');
|
||||
document.getElementById('selectedDuplicateId').value = '';
|
||||
document.getElementById('disposeModal').classList.remove('hidden');
|
||||
|
||||
// 중복 선택 영역 표시 (기본값이 duplicate이므로)
|
||||
toggleDuplicateSelection();
|
||||
}
|
||||
|
||||
// 폐기 모달 닫기
|
||||
function closeDisposeModal() {
|
||||
currentIssueId = null;
|
||||
document.getElementById('disposeModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 사용자 정의 사유 토글
|
||||
function toggleCustomReason() {
|
||||
const reason = document.getElementById('disposalReason').value;
|
||||
const customDiv = document.getElementById('customReasonDiv');
|
||||
|
||||
if (reason === 'custom') {
|
||||
customDiv.classList.remove('hidden');
|
||||
} else {
|
||||
customDiv.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 대상 선택 토글
|
||||
function toggleDuplicateSelection() {
|
||||
const reason = document.getElementById('disposalReason').value;
|
||||
const duplicateDiv = document.getElementById('duplicateSelectionDiv');
|
||||
|
||||
if (reason === 'duplicate') {
|
||||
duplicateDiv.classList.remove('hidden');
|
||||
loadManagementIssues();
|
||||
} else {
|
||||
duplicateDiv.classList.add('hidden');
|
||||
document.getElementById('selectedDuplicateId').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 관리함 이슈 목록 로드
|
||||
async function loadManagementIssues() {
|
||||
const currentIssue = issues.find(issue => issue.id === currentIssueId);
|
||||
const projectId = currentIssue ? currentIssue.project_id : null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/inbox/management-issues${projectId ? `?project_id=${projectId}` : ''}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('관리함 이슈 목록을 불러올 수 없습니다.');
|
||||
}
|
||||
|
||||
const managementIssues = await response.json();
|
||||
displayManagementIssues(managementIssues);
|
||||
|
||||
} catch (error) {
|
||||
console.error('관리함 이슈 로드 오류:', error);
|
||||
document.getElementById('managementIssuesList').innerHTML = `
|
||||
<div class="p-4 text-center text-red-500">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>이슈 목록을 불러올 수 없습니다.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 관리함 이슈 목록 표시
|
||||
function displayManagementIssues(managementIssues) {
|
||||
const container = document.getElementById('managementIssuesList');
|
||||
|
||||
if (managementIssues.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="p-4 text-center text-gray-500">
|
||||
<i class="fas fa-inbox mr-2"></i>동일 프로젝트의 관리함 이슈가 없습니다.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = managementIssues.map(issue => `
|
||||
<div class="p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer"
|
||||
onclick="selectDuplicateTarget(${issue.id}, this)">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-gray-900 mb-1">
|
||||
${issue.description || issue.final_description}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span class="px-2 py-1 bg-gray-100 rounded">${getCategoryText(issue.category || issue.final_category)}</span>
|
||||
<span>신고자: ${issue.reporter_name}</span>
|
||||
${issue.duplicate_count > 0 ? `<span class="text-orange-600">중복 ${issue.duplicate_count}건</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
ID: ${issue.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 중복 대상 선택
|
||||
function selectDuplicateTarget(issueId, element) {
|
||||
// 이전 선택 해제
|
||||
document.querySelectorAll('#managementIssuesList > div').forEach(div => {
|
||||
div.classList.remove('bg-blue-50', 'border-blue-200');
|
||||
});
|
||||
|
||||
// 현재 선택 표시
|
||||
element.classList.add('bg-blue-50', 'border-blue-200');
|
||||
document.getElementById('selectedDuplicateId').value = issueId;
|
||||
}
|
||||
|
||||
// 폐기 확인
|
||||
async function confirmDispose() {
|
||||
if (!currentIssueId) return;
|
||||
|
||||
const disposalReason = document.getElementById('disposalReason').value;
|
||||
const customReason = document.getElementById('customReason').value;
|
||||
const duplicateId = document.getElementById('selectedDuplicateId').value;
|
||||
|
||||
// 사용자 정의 사유 검증
|
||||
if (disposalReason === 'custom' && !customReason.trim()) {
|
||||
alert('사용자 정의 폐기 사유를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 중복 대상 선택 검증
|
||||
if (disposalReason === 'duplicate' && !duplicateId) {
|
||||
alert('중복 대상을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
disposal_reason: disposalReason,
|
||||
custom_disposal_reason: disposalReason === 'custom' ? customReason : null
|
||||
};
|
||||
|
||||
// 중복 처리인 경우 대상 ID 추가
|
||||
if (disposalReason === 'duplicate' && duplicateId) {
|
||||
requestBody.duplicate_of_issue_id = parseInt(duplicateId);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/inbox/${currentIssueId}/dispose`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const message = disposalReason === 'duplicate'
|
||||
? '중복 신고가 처리되었습니다.\n신고자 정보가 원본 이슈에 추가되었습니다.'
|
||||
: `부적합이 성공적으로 폐기되었습니다.\n사유: ${getDisposalReasonText(disposalReason)}`;
|
||||
|
||||
alert(message);
|
||||
closeDisposeModal();
|
||||
await loadIssues(); // 목록 새로고침
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || '폐기 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('폐기 처리 오류:', error);
|
||||
alert('폐기 처리 중 오류가 발생했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 검토 모달 열기
|
||||
async function openReviewModal(issueId) {
|
||||
currentIssueId = issueId;
|
||||
|
||||
// 현재 부적합 정보 찾기
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
if (!issue) return;
|
||||
|
||||
// 원본 정보 표시
|
||||
const originalInfo = document.getElementById('originalInfo');
|
||||
const project = projects.find(p => p.id === issue.project_id);
|
||||
originalInfo.innerHTML = `
|
||||
<div class="space-y-2">
|
||||
<div><strong>프로젝트:</strong> ${project ? project.project_name : '미지정'}</div>
|
||||
<div><strong>카테고리:</strong> ${getCategoryText(issue.category || issue.final_category)}</div>
|
||||
<div><strong>설명:</strong> ${issue.description || issue.final_description}</div>
|
||||
<div><strong>등록자:</strong> ${issue.reporter?.username || '알 수 없음'}</div>
|
||||
<div><strong>등록일:</strong> ${new Date(issue.report_date).toLocaleDateString('ko-KR')}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 프로젝트 옵션 업데이트
|
||||
const reviewProjectSelect = document.getElementById('reviewProjectId');
|
||||
reviewProjectSelect.innerHTML = '<option value="">프로젝트 선택</option>';
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = project.project_name;
|
||||
if (project.id === issue.project_id) {
|
||||
option.selected = true;
|
||||
}
|
||||
reviewProjectSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 현재 값들로 폼 초기화 (최신 내용 우선 사용)
|
||||
document.getElementById('reviewCategory').value = issue.category || issue.final_category;
|
||||
// 최신 description을 title과 description으로 분리 (첫 번째 줄을 title로 사용)
|
||||
const currentDescription = issue.description || issue.final_description;
|
||||
const lines = currentDescription.split('\n');
|
||||
document.getElementById('reviewTitle').value = lines[0] || '';
|
||||
document.getElementById('reviewDescription').value = lines.slice(1).join('\n') || currentDescription;
|
||||
|
||||
document.getElementById('reviewModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 검토 모달 닫기
|
||||
function closeReviewModal() {
|
||||
currentIssueId = null;
|
||||
document.getElementById('reviewModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 검토 저장
|
||||
async function saveReview() {
|
||||
if (!currentIssueId) return;
|
||||
|
||||
const projectId = document.getElementById('reviewProjectId').value;
|
||||
const category = document.getElementById('reviewCategory').value;
|
||||
const title = document.getElementById('reviewTitle').value.trim();
|
||||
const description = document.getElementById('reviewDescription').value.trim();
|
||||
|
||||
if (!title) {
|
||||
alert('부적합명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 부적합명과 상세 내용을 합쳐서 저장 (첫 번째 줄에 제목, 나머지는 상세 내용)
|
||||
const combinedDescription = title + (description ? '\n' + description : '');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/inbox/${currentIssueId}/review`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
project_id: projectId ? parseInt(projectId) : null,
|
||||
category: category,
|
||||
description: combinedDescription
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
alert(`검토가 완료되었습니다.\n수정된 항목: ${result.modifications_count}개`);
|
||||
closeReviewModal();
|
||||
await loadIssues(); // 목록 새로고침
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || '검토 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('검토 처리 오류:', error);
|
||||
alert('검토 처리 중 오류가 발생했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 모달 열기
|
||||
function openStatusModal(issueId) {
|
||||
currentIssueId = issueId;
|
||||
|
||||
// 라디오 버튼 초기화
|
||||
document.querySelectorAll('input[name="finalStatus"]').forEach(radio => {
|
||||
radio.checked = false;
|
||||
});
|
||||
|
||||
document.getElementById('statusModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 상태 모달 닫기
|
||||
function closeStatusModal() {
|
||||
currentIssueId = null;
|
||||
document.getElementById('statusModal').classList.add('hidden');
|
||||
// 완료 관련 필드 초기화
|
||||
document.getElementById('completionSection').classList.add('hidden');
|
||||
document.getElementById('completionPhotoInput').value = '';
|
||||
document.getElementById('completionPhotoPreview').classList.add('hidden');
|
||||
document.getElementById('solutionInput').value = '';
|
||||
document.getElementById('responsibleDepartmentInput').value = '';
|
||||
document.getElementById('responsiblePersonInput').value = '';
|
||||
completionPhotoBase64 = null;
|
||||
}
|
||||
|
||||
// 완료 섹션 토글
|
||||
function toggleCompletionPhotoSection() {
|
||||
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
|
||||
const completionSection = document.getElementById('completionSection');
|
||||
|
||||
if (selectedStatus && selectedStatus.value === 'completed') {
|
||||
completionSection.classList.remove('hidden');
|
||||
} else {
|
||||
completionSection.classList.add('hidden');
|
||||
// 완료 관련 필드 초기화
|
||||
document.getElementById('completionPhotoInput').value = '';
|
||||
document.getElementById('completionPhotoPreview').classList.add('hidden');
|
||||
document.getElementById('solutionInput').value = '';
|
||||
document.getElementById('responsibleDepartmentInput').value = '';
|
||||
document.getElementById('responsiblePersonInput').value = '';
|
||||
completionPhotoBase64 = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 완료 사진 선택 처리
|
||||
let completionPhotoBase64 = null;
|
||||
function handleCompletionPhotoSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
completionPhotoBase64 = null;
|
||||
document.getElementById('completionPhotoPreview').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 크기 체크 (5MB 제한)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('파일 크기는 5MB 이하여야 합니다.');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미지 파일인지 확인
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('이미지 파일만 업로드 가능합니다.');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
completionPhotoBase64 = e.target.result.split(',')[1]; // Base64 부분만 추출
|
||||
|
||||
// 미리보기 표시
|
||||
document.getElementById('completionPhotoImg').src = e.target.result;
|
||||
document.getElementById('completionPhotoPreview').classList.remove('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// 상태 변경 확인
|
||||
async function confirmStatus() {
|
||||
if (!currentIssueId) return;
|
||||
|
||||
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
|
||||
if (!selectedStatus) {
|
||||
alert('상태를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewStatus = selectedStatus.value;
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
review_status: reviewStatus
|
||||
};
|
||||
|
||||
// 완료 상태일 때 추가 정보 수집
|
||||
if (reviewStatus === 'completed') {
|
||||
// 완료 사진
|
||||
if (completionPhotoBase64) {
|
||||
requestBody.completion_photo = completionPhotoBase64;
|
||||
}
|
||||
|
||||
// 해결방안
|
||||
const solution = document.getElementById('solutionInput').value.trim();
|
||||
if (solution) {
|
||||
requestBody.solution = solution;
|
||||
}
|
||||
|
||||
// 담당부서
|
||||
const responsibleDepartment = document.getElementById('responsibleDepartmentInput').value;
|
||||
if (responsibleDepartment) {
|
||||
requestBody.responsible_department = responsibleDepartment;
|
||||
}
|
||||
|
||||
// 담당자
|
||||
const responsiblePerson = document.getElementById('responsiblePersonInput').value.trim();
|
||||
if (responsiblePerson) {
|
||||
requestBody.responsible_person = responsiblePerson;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/inbox/${currentIssueId}/status`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(currentIssueId);
|
||||
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 = new Date();
|
||||
const target = new Date(date);
|
||||
const diffMs = now - target;
|
||||
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);
|
||||
}
|
||||
|
||||
// AI 분류 추천
|
||||
async function aiClassifyCurrentIssue() {
|
||||
if (!currentIssueId || typeof AiAPI === 'undefined') return;
|
||||
const issue = issues.find(i => i.id === currentIssueId);
|
||||
if (!issue) return;
|
||||
|
||||
const btn = document.getElementById('aiClassifyBtn');
|
||||
const loading = document.getElementById('aiClassifyLoading');
|
||||
const result = document.getElementById('aiClassifyResult');
|
||||
if (btn) btn.disabled = true;
|
||||
if (loading) loading.classList.remove('hidden');
|
||||
if (result) result.classList.add('hidden');
|
||||
|
||||
// RAG 강화 분류 사용 (과거 사례 참고)
|
||||
const classifyFn = AiAPI.classifyWithRAG || AiAPI.classifyIssue;
|
||||
const data = await classifyFn(
|
||||
issue.description || issue.final_description || '',
|
||||
issue.detail_notes || ''
|
||||
);
|
||||
|
||||
if (loading) loading.classList.add('hidden');
|
||||
if (btn) btn.disabled = false;
|
||||
|
||||
if (!data.available) {
|
||||
if (result) {
|
||||
result.innerHTML = '<p class="text-xs text-red-500">AI 서비스를 사용할 수 없습니다</p>';
|
||||
result.classList.remove('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryMap = {
|
||||
'material_missing': '자재 누락',
|
||||
'design_error': '설계 오류',
|
||||
'incoming_defect': '반입 불량',
|
||||
'inspection_miss': '검사 누락',
|
||||
};
|
||||
const deptMap = {
|
||||
'production': '생산',
|
||||
'quality': '품질',
|
||||
'purchasing': '구매',
|
||||
'design': '설계',
|
||||
'sales': '영업',
|
||||
};
|
||||
|
||||
const cat = data.category || '';
|
||||
const dept = data.responsible_department || '';
|
||||
const severity = data.severity || '';
|
||||
const summary = data.summary || '';
|
||||
const confidence = data.category_confidence ? Math.round(data.category_confidence * 100) : '';
|
||||
|
||||
result.innerHTML = `
|
||||
<div class="space-y-1">
|
||||
<p><strong>분류:</strong> ${categoryMap[cat] || cat} ${confidence ? `(${confidence}%)` : ''}</p>
|
||||
<p><strong>부서:</strong> ${deptMap[dept] || dept}</p>
|
||||
<p><strong>심각도:</strong> ${severity}</p>
|
||||
${summary ? `<p><strong>요약:</strong> ${summary}</p>` : ''}
|
||||
<button onclick="applyAiClassification('${cat}')"
|
||||
class="mt-2 px-3 py-1 bg-purple-600 text-white text-xs rounded hover:bg-purple-700">
|
||||
<i class="fas fa-check mr-1"></i>적용
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
result.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function applyAiClassification(category) {
|
||||
const reviewCategory = document.getElementById('reviewCategory');
|
||||
if (reviewCategory && category) {
|
||||
reviewCategory.value = category;
|
||||
}
|
||||
if (window.showToast) {
|
||||
window.showToast('AI 추천이 적용되었습니다', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화 (api.js는 HTML에서 로드됨)
|
||||
initializeInbox();
|
||||
File diff suppressed because it is too large
Load Diff
39
system3-nonconformance/web/public/static/js/sso-relay.js
Normal file
39
system3-nonconformance/web/public/static/js/sso-relay.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* SSO Token Relay — 인앱 브라우저(카카오톡 등) 서브도메인 쿠키 미공유 대응
|
||||
*
|
||||
* Canonical source: shared/frontend/sso-relay.js
|
||||
* 전 서비스 동일 코드 — 수정 시 아래 파일 <20><><EFBFBD>체 갱신 필요:
|
||||
* system1-factory/web/js/sso-relay.js
|
||||
* system2-report/web/js/sso-relay.js
|
||||
* system3-nonconformance/web/static/js/sso-relay.js
|
||||
* user-management/web/static/js/sso-relay.js
|
||||
* tkpurchase/web/static/js/sso-relay.js
|
||||
* tksafety/web/static/js/sso-relay.js
|
||||
* tksupport/web/static/js/sso-relay.js
|
||||
*
|
||||
* 동작: URL hash에 _sso= 파라미터가 있으면 토큰을 로컬 쿠키+localStorage에 설정하고 hash를 제거.
|
||||
* gateway/dashboard.html에서 로그인 성공 후 redirect URL에 #_sso=<token>을 붙여 전달.
|
||||
*/
|
||||
(function() {
|
||||
var hash = location.hash;
|
||||
if (!hash || hash.indexOf('_sso=') === -1) return;
|
||||
|
||||
var match = hash.match(/[#&]_sso=([^&]*)/);
|
||||
if (!match) return;
|
||||
|
||||
var token = decodeURIComponent(match[1]);
|
||||
if (!token) return;
|
||||
|
||||
// 로컬(1st-party) 쿠키 설정
|
||||
var cookie = 'sso_token=' + encodeURIComponent(token) + '; path=/; max-age=604800';
|
||||
if (location.hostname.indexOf('technicalkorea.net') !== -1) {
|
||||
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
|
||||
// localStorage 폴백
|
||||
try { localStorage.setItem('sso_token', token); } catch (e) {}
|
||||
|
||||
// URL에서 hash 제거
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
})();
|
||||
@@ -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(', ');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* photo-modal.js — 사진 확대 모달 공통 모듈
|
||||
* dashboard, management, inbox, issue-view 등에서 공유
|
||||
*/
|
||||
|
||||
function openPhotoModal(photoPath) {
|
||||
if (!photoPath) return;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'photo-modal-overlay';
|
||||
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="photo-modal-content">
|
||||
<img src="${photoPath}" alt="확대된 사진">
|
||||
<button class="photo-modal-close" onclick="this.closest('.photo-modal-overlay').remove()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// ESC 키로 닫기
|
||||
const handleEsc = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
}
|
||||
|
||||
// 기존 코드 호환용 별칭
|
||||
function showImageModal(imagePath) {
|
||||
openPhotoModal(imagePath);
|
||||
}
|
||||
|
||||
function closePhotoModal() {
|
||||
const modal = document.querySelector('.photo-modal-overlay');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
45
system3-nonconformance/web/public/static/js/utils/toast.js
Normal file
45
system3-nonconformance/web/public/static/js/utils/toast.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* toast.js — 토스트 알림 공통 모듈
|
||||
*/
|
||||
|
||||
function showToast(message, type = 'success', duration = 3000) {
|
||||
const existing = document.querySelector('.toast-notification');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const iconMap = {
|
||||
success: 'fas fa-check-circle',
|
||||
error: 'fas fa-exclamation-circle',
|
||||
warning: 'fas fa-exclamation-triangle',
|
||||
info: 'fas fa-info-circle'
|
||||
};
|
||||
|
||||
const colorMap = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
warning: 'bg-yellow-500',
|
||||
info: 'bg-blue-500'
|
||||
};
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification fixed top-4 right-4 z-[9999] ${colorMap[type] || colorMap.info} text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-2 transform translate-x-full transition-transform duration-300`;
|
||||
toast.innerHTML = `
|
||||
<i class="${iconMap[type] || iconMap.info}"></i>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(120%)';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// 기존 코드 호환용 별칭
|
||||
function showToastMessage(message, type = 'success') {
|
||||
showToast(message, type);
|
||||
}
|
||||
Reference in New Issue
Block a user