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:
Hyungi Ahn
2026-04-10 09:44:21 +09:00
parent bbffa47a9d
commit ba9ef32808
257 changed files with 786 additions and 18 deletions

View File

@@ -0,0 +1,287 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 어시스턴트</title>
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=2026031401">
<link rel="stylesheet" href="/static/css/ai-assistant.css?v=2026031401">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="/static/js/lib/purify.min.js"></script>
</head>
<body>
<!-- 로딩 스크린 -->
<div id="loadingScreen" class="fixed inset-0 bg-white z-50 flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600 mx-auto mb-4"></div>
<p class="text-gray-600">AI 어시스턴트를 불러오는 중...</p>
</div>
</div>
<!-- 메인 콘텐츠 -->
<div id="mainContent" class="min-h-screen">
<!-- 공통 헤더 -->
<div id="commonHeader"></div>
<main class="container mx-auto px-4 py-8 content-fade-in" style="padding-top: 72px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-robot text-purple-500 mr-3"></i>
AI 어시스턴트
</h1>
<p class="text-gray-600 mt-1">AI 기반 부적합 분석, 검색, 질의응답을 한곳에서 사용하세요</p>
</div>
</div>
</div>
<!-- 1. 상태 카드 (3열 그리드) -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="status-card bg-white rounded-xl shadow-sm p-5 border-l-4 border-purple-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">AI 서비스</p>
<p class="text-lg font-bold mt-1" id="aiStatusText">확인 중...</p>
</div>
<div id="aiStatusIcon" class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<i class="fas fa-spinner fa-spin text-gray-400"></i>
</div>
</div>
</div>
<div class="status-card bg-white rounded-xl shadow-sm p-5 border-l-4 border-indigo-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">임베딩 데이터</p>
<p class="text-lg font-bold mt-1" id="aiEmbeddingCount">-</p>
</div>
<div class="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center">
<i class="fas fa-database text-indigo-500"></i>
</div>
</div>
</div>
<div class="status-card bg-white rounded-xl shadow-sm p-5 border-l-4 border-blue-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">AI 모델</p>
<p class="text-lg font-bold mt-1" id="aiModelName">-</p>
</div>
<div class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
<i class="fas fa-brain text-blue-500"></i>
</div>
</div>
</div>
</div>
<!-- 2. AI Q&A (메인 — 채팅형) -->
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center">
<i class="fas fa-comments text-purple-500 mr-2"></i>
<h2 class="text-lg font-semibold text-gray-800">AI Q&A</h2>
<span class="ml-2 text-xs bg-purple-100 text-purple-600 px-2 py-0.5 rounded-full">과거 사례 기반</span>
</div>
<button onclick="clearChat()" class="text-sm text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-trash-alt mr-1"></i>대화 초기화
</button>
</div>
<!-- 채팅 히스토리 -->
<div id="chatContainer" class="chat-container bg-gray-50 rounded-lg p-4 mb-4 min-h-[200px]">
<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>
</div>
<!-- 빠른 질문 템플릿 -->
<div class="flex flex-wrap gap-2 mb-3">
<button onclick="setQuickQuestion('최근 가장 많이 발생하는 부적합 유형은?')"
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
<i class="fas fa-chart-pie mr-1 text-purple-400"></i>많이 발생하는 유형
</button>
<button onclick="setQuickQuestion('용접 불량의 주요 원인과 해결방법은?')"
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
<i class="fas fa-fire mr-1 text-orange-400"></i>용접 불량 원인
</button>
<button onclick="setQuickQuestion('자재 관련 부적합을 줄이려면 어떻게 해야 하나요?')"
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
<i class="fas fa-box mr-1 text-blue-400"></i>자재 부적합 개선
</button>
<button onclick="setQuickQuestion('반복적으로 발생하는 부적합 패턴이 있나요?')"
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
<i class="fas fa-redo mr-1 text-red-400"></i>반복 패턴 분석
</button>
</div>
<!-- 입력 영역 -->
<div class="flex flex-col md:flex-row gap-2">
<div class="flex-1">
<textarea id="qaQuestion" rows="3"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none text-sm"
placeholder="질문을 입력하세요... (Ctrl+Enter로 전송)"
onkeydown="if(event.ctrlKey && event.key==='Enter') submitQuestion()"></textarea>
</div>
<div class="flex flex-col gap-2 md:w-48">
<select id="qaProjectFilter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500">
<option value="">전체 프로젝트</option>
</select>
<button onclick="submitQuestion()"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
<i class="fas fa-paper-plane mr-1"></i>질문하기
</button>
</div>
</div>
</div>
<!-- 3. 시맨틱 검색 -->
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center mb-4">
<i class="fas fa-search text-indigo-500 mr-2"></i>
<h2 class="text-lg font-semibold text-gray-800">시맨틱 검색</h2>
<span class="ml-2 text-xs bg-indigo-100 text-indigo-600 px-2 py-0.5 rounded-full">유사 부적합 찾기</span>
</div>
<div class="flex flex-col md:flex-row gap-2 mb-4">
<input type="text" id="searchQuery"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm"
placeholder="부적합 내용을 자연어로 검색하세요... (예: 볼트 누락, 용접 불량)"
onkeydown="if(event.key==='Enter') executeSemanticSearch()">
<select id="searchProjectFilter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 md:w-40">
<option value="">전체 프로젝트</option>
</select>
<select id="searchCategoryFilter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 md:w-40">
<option value="">전체 카테고리</option>
<option value="civil">토목</option>
<option value="architecture">건축</option>
<option value="mechanical">기계</option>
<option value="electrical">전기</option>
<option value="piping">배관</option>
<option value="instrument">계장</option>
<option value="painting">도장</option>
<option value="insulation">보온</option>
<option value="fireproof">내화</option>
<option value="other">기타</option>
</select>
<select id="searchResultCount" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 md:w-28">
<option value="5">5건</option>
<option value="10" selected>10건</option>
<option value="20">20건</option>
</select>
<button onclick="executeSemanticSearch()"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm whitespace-nowrap">
<i class="fas fa-search mr-1"></i>검색
</button>
</div>
<div id="searchLoading" class="hidden text-center py-4">
<i class="fas fa-spinner fa-spin text-indigo-500 mr-1"></i>
<span class="text-sm text-gray-500">AI 검색 중...</span>
</div>
<div id="searchResults" class="space-y-2">
<!-- 검색 결과 -->
</div>
</div>
<!-- 4. 패턴 분석 -->
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center mb-4">
<i class="fas fa-chart-bar text-green-500 mr-2"></i>
<h2 class="text-lg font-semibold text-gray-800">패턴 분석</h2>
<span class="ml-2 text-xs bg-green-100 text-green-600 px-2 py-0.5 rounded-full">부적합 패턴 파악</span>
</div>
<div class="flex flex-col md:flex-row gap-2 mb-4">
<textarea id="patternInput" rows="2"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none text-sm"
placeholder="분석할 부적합 내용을 입력하세요... (예: 배관 용접부 결함)"></textarea>
<button onclick="executePatternAnalysis()"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm whitespace-nowrap self-end">
<i class="fas fa-chart-bar mr-1"></i>패턴 분석
</button>
</div>
<div id="patternLoading" class="hidden text-center py-4">
<i class="fas fa-spinner fa-spin text-green-500 mr-1"></i>
<span class="text-sm text-gray-500">패턴 분석 중...</span>
</div>
<div id="patternResults" class="hidden">
<!-- 패턴 분석 결과 -->
</div>
</div>
<!-- 5. AI 분류 테스트 -->
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center mb-4">
<i class="fas fa-tags text-amber-500 mr-2"></i>
<h2 class="text-lg font-semibold text-gray-800">AI 분류 테스트</h2>
<span class="ml-2 text-xs bg-amber-100 text-amber-600 px-2 py-0.5 rounded-full">기본 vs RAG 비교</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">부적합 설명</label>
<textarea id="classifyDescription" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 resize-none text-sm"
placeholder="부적합 설명을 입력하세요..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상세 내용 (선택)</label>
<textarea id="classifyDetail" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 resize-none text-sm"
placeholder="상세 내용을 입력하세요..."></textarea>
</div>
</div>
<div class="flex gap-2 mb-4">
<button onclick="executeClassification(false)"
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors text-sm">
<i class="fas fa-tag mr-1"></i>기본 분류
</button>
<button onclick="executeClassification(true)"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm">
<i class="fas fa-tags mr-1"></i>RAG 분류
</button>
</div>
<div id="classifyLoading" class="hidden text-center py-4">
<i class="fas fa-spinner fa-spin text-amber-500 mr-1"></i>
<span class="text-sm text-gray-500">AI 분류 중...</span>
</div>
<div id="classifyResults" class="hidden">
<!-- 분류 결과 -->
</div>
</div>
</main>
</div>
<!-- AI 이슈 상세 모달 -->
<div id="aiIssueModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
<div class="sticky top-0 bg-white border-b px-5 py-3 flex justify-between items-center rounded-t-xl">
<h3 id="aiIssueModalTitle" class="font-bold text-gray-800"></h3>
<button onclick="document.getElementById('aiIssueModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div id="aiIssueModalBody" class="p-5 text-sm text-gray-700 space-y-3"></div>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/core/page-manager.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script src="/static/js/utils/issue-helpers.js?v=2026031401"></script>
<script src="/static/js/utils/toast.js?v=2026031401"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=2026031401"></script>
<script src="/static/js/pages/ai-assistant.js?v=2026031401"></script>
</body>
</html>

View File

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업보고서 시스템</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
min-height: 100vh;
}
.sidebar {
transition: transform 0.3s ease-in-out;
}
.sidebar.collapsed {
transform: translateX(-100%);
}
.main-content {
transition: margin-left 0.3s ease-in-out;
}
.main-content.expanded {
margin-left: 0;
}
.nav-item {
transition: all 0.2s ease;
}
.nav-item:hover {
background-color: rgba(59, 130, 246, 0.1);
transform: translateX(4px);
}
.nav-item.active {
background-color: #3b82f6;
color: white;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-overlay.active {
display: flex;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.input-field {
border: 1px solid #d1d5db;
background: white;
transition: all 0.2s ease;
}
.input-field:focus {
border-color: #3b82f6;
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 모바일 반응형 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
z-index: 1000;
height: 100vh;
}
.main-content {
margin-left: 0 !important;
}
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: none;
}
.mobile-overlay.active {
display: block;
}
}
</style>
</head>
<body>
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="loading-overlay">
<div class="bg-white rounded-xl p-8 text-center">
<div class="mb-4">
<i class="fas fa-spinner fa-spin text-5xl text-blue-500"></i>
</div>
<p class="text-gray-700 font-medium text-lg">처리 중입니다...</p>
<p class="text-gray-500 text-sm mt-2">잠시만 기다려주세요</p>
</div>
</div>
<!-- 모바일 오버레이 -->
<div id="mobileOverlay" class="mobile-overlay" onclick="toggleSidebar()"></div>
<!-- 사이드바 -->
<aside id="sidebar" class="sidebar fixed left-0 top-0 h-full w-64 bg-white shadow-lg z-50">
<!-- 헤더 -->
<div class="p-6 border-b">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-clipboard-check text-2xl text-blue-500 mr-3"></i>
<h1 class="text-xl font-bold text-gray-800">작업보고서</h1>
</div>
<button onclick="toggleSidebar()" class="md:hidden text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 사용자 정보 -->
<div class="p-4 border-b bg-gray-50">
<div class="flex items-center">
<div class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-semibold">
<span id="userInitial">U</span>
</div>
<div class="ml-3">
<p class="font-medium text-gray-800" id="userDisplayName">사용자</p>
<p class="text-sm text-gray-500" id="userRole">user</p>
</div>
</div>
</div>
<!-- 네비게이션 메뉴 -->
<nav class="p-4">
<ul id="navigationMenu" class="space-y-2">
<!-- 메뉴 항목들이 동적으로 생성됩니다 -->
</ul>
</nav>
<!-- 하단 메뉴 -->
<div class="absolute bottom-0 left-0 right-0 p-4 border-t bg-gray-50">
<button onclick="CommonHeader.showPasswordModal()" class="w-full text-left p-2 rounded-lg hover:bg-gray-100 transition-colors">
<i class="fas fa-key mr-3 text-gray-500"></i>
<span class="text-gray-700">비밀번호 변경</span>
</button>
<button onclick="logout()" class="w-full text-left p-2 rounded-lg hover:bg-red-50 text-red-600 transition-colors mt-2">
<i class="fas fa-sign-out-alt mr-3"></i>
<span>로그아웃</span>
</button>
</div>
</aside>
<!-- 메인 콘텐츠 -->
<main id="mainContent" class="main-content ml-64 min-h-screen">
<!-- 상단 바 -->
<header class="bg-white shadow-sm border-b p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<button onclick="toggleSidebar()" class="md:hidden mr-4 text-gray-500 hover:text-gray-700">
<i class="fas fa-bars"></i>
</button>
<h2 id="pageTitle" class="text-xl font-semibold text-gray-800">대시보드</h2>
</div>
<div class="flex items-center space-x-4">
<button class="p-2 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100">
<i class="fas fa-bell"></i>
</button>
<button class="p-2 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100">
<i class="fas fa-cog"></i>
</button>
</div>
</div>
</header>
<!-- 페이지 콘텐츠 -->
<div id="pageContent" class="p-6">
<!-- 기본 대시보드 -->
<div id="dashboard" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- 통계 카드들 -->
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">총 부적합 사항</p>
<p class="text-2xl font-bold text-gray-800" id="totalIssues">0</p>
</div>
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-500"></i>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">진행 중인 프로젝트</p>
<p class="text-2xl font-bold text-gray-800" id="activeProjects">0</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-folder-open text-blue-500"></i>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">이번 달 공수</p>
<p class="text-2xl font-bold text-gray-800" id="monthlyHours">0</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-clock text-green-500"></i>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">완료율</p>
<p class="text-2xl font-bold text-gray-800" id="completionRate">0%</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-chart-pie text-purple-500"></i>
</div>
</div>
</div>
</div>
<!-- 최근 활동 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">최근 부적합 사항</h3>
<div id="recentIssues" class="space-y-3">
<!-- 최근 부적합 사항 목록 -->
</div>
</div>
<div class="card p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">프로젝트 현황</h3>
<div id="projectStatus" class="space-y-3">
<!-- 프로젝트 현황 -->
</div>
</div>
</div>
</div>
<!-- 동적 콘텐츠 영역 -->
<div id="dynamicContent" class="hidden">
<!-- 각 모듈의 콘텐츠가 여기에 로드됩니다 -->
</div>
</div>
</main>
<!-- Scripts -->
<script src="/static/js/utils/date-utils.js"></script>
<script src="/static/js/utils/image-utils.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/app.js?v=2026040101"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>부적합 사항 조회 - 작업보고서</title>
<!-- Tailwind CSS -->
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- 공통 스타일 -->
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=2026031401">
<!-- 페이지 전용 스타일 -->
<link rel="stylesheet" href="/static/css/issue-view.css?v=2026031401">
</head>
<body>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8 content-fade-in" style="padding-top: 72px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="mb-4">
<h1 id="pageTitle" class="text-xl font-bold text-gray-900 flex items-center">
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
내 부적합 조회
</h1>
<p id="pageDescription" class="text-sm text-gray-600 mt-1">
내가 등록한 부적합 사항을 확인할 수 있습니다
</p>
</div>
<!-- 필터 섹션 -->
<div class="space-y-4 mb-6">
<!-- 기본 필터들 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 프로젝트 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📁 프로젝트</label>
<select id="projectFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
<!-- 프로젝트 옵션들이 여기에 로드됩니다 -->
</select>
</div>
<!-- 워크플로우 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📋 워크플로우 상태</label>
<select id="reviewStatusFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" onchange="filterIssues()">
<option value="">전체</option>
<option value="pending_review">수신함 (검토 대기)</option>
<option value="in_progress">관리함 (진행 중)</option>
<option value="completed">관리함 (완료됨)</option>
<option value="disposed">폐기함 (폐기됨)</option>
</select>
</div>
</div>
<!-- 날짜 필터 (시작/끝 날짜) -->
<div>
<label class="text-sm font-medium text-gray-700 mb-3 block">📅 기간 선택</label>
<!-- 빠른 선택 버튼 -->
<div class="flex flex-wrap gap-2 mb-3">
<button onclick="setDateRange('today')" class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors">오늘</button>
<button onclick="setDateRange('week')" class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors">이번 주</button>
<button onclick="setDateRange('month')" class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors">이번 달</button>
<button onclick="setDateRange('all')" class="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors">전체</button>
</div>
<!-- 날짜 입력 필드 -->
<div class="space-y-2">
<div>
<label class="text-xs text-gray-600 mb-1 block">시작날짜:</label>
<input type="date" id="startDateInput" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="text-xs text-gray-600 mb-1 block">끝날짜:</label>
<input type="date" id="endDateInput" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<button onclick="applyDateFilter()" class="w-full px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-search mr-2"></i>조회
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 결과 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<div id="issueResults" class="space-y-3">
<!-- 결과가 여기에 표시됩니다 -->
<div class="text-gray-500 text-center py-8">
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
<p>데이터를 불러오는 중...</p>
</div>
</div>
</div>
</main>
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=2026031401"></script>
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/core/page-manager.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script src="/static/js/utils/issue-helpers.js?v=2026031401"></script>
<script src="/static/js/utils/photo-modal.js?v=2026031401"></script>
<script src="/static/js/utils/toast.js?v=2026031401"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=2026031401"></script>
<script src="/static/js/pages/issue-view.js?v=2026041001"></script>
</body>
</html>

View File

@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>폐기함 - 작업보고서</title>
<!-- Tailwind CSS -->
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- 모바일 캘린더 스타일 -->
<link rel="stylesheet" href="/static/css/mobile-calendar.css">
<!-- 공통 스타일 -->
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=2026031401">
<!-- 페이지 전용 스타일 -->
<link rel="stylesheet" href="/static/css/issues-archive.css?v=2026031401">
</head>
<body>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 72px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-archive text-gray-500 mr-3"></i>
폐기함
</h1>
<p class="text-gray-600 mt-1">완료되거나 폐기된 부적합 사항을 보관하고 분석하세요</p>
</div>
<div class="flex items-center space-x-3">
<button onclick="generateReport()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-chart-bar mr-2"></i>
통계 보고서
</button>
<button onclick="cleanupArchive()" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-2"></i>
정리하기
</button>
</div>
</div>
<!-- 아카이브 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-green-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-green-600">완료</p>
<p class="text-2xl font-bold text-green-700" id="completedCount">0</p>
</div>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-archive text-gray-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-gray-600">보관</p>
<p class="text-2xl font-bold text-gray-700" id="archivedCount">0</p>
</div>
</div>
</div>
<div class="bg-red-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-times-circle text-red-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-red-600">취소</p>
<p class="text-2xl font-bold text-red-700" id="cancelledCount">0</p>
</div>
</div>
</div>
<div class="bg-purple-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-calendar-alt text-purple-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-purple-600">이번 달</p>
<p class="text-2xl font-bold text-purple-700" id="thisMonthCount">0</p>
</div>
</div>
</div>
</div>
</div>
<!-- 필터 및 검색 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<!-- 프로젝트 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📁 프로젝트</label>
<select id="projectFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📋 상태</label>
<select id="statusFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체</option>
<option value="completed">완료</option>
<option value="archived">보관</option>
<option value="cancelled">취소</option>
</select>
</div>
<!-- 기간 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📅 기간</label>
<select id="periodFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체 기간</option>
<option value="week">이번 주</option>
<option value="month">이번 달</option>
<option value="quarter">이번 분기</option>
<option value="year">올해</option>
</select>
</div>
<!-- 카테고리 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">🏷️ 카테고리</label>
<select id="categoryFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체 카테고리</option>
<option value="material_missing">자재 누락</option>
<option value="design_error">설계 오류</option>
<option value="incoming_defect">반입 불량</option>
<option value="inspection_miss">검사 누락</option>
<option value="etc">기타</option>
</select>
</div>
<!-- 검색 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">🔍 검색</label>
<input type="text" id="searchInput" placeholder="설명 또는 등록자 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500"
onkeyup="filterIssues()">
</div>
</div>
</div>
<!-- 통계 차트 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- 월별 완료 현황 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">월별 완료 현황</h3>
<div class="chart-container">
<canvas id="monthlyChart"></canvas>
</div>
</div>
<!-- 카테고리별 분포 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">카테고리별 분포</h3>
<div class="chart-container">
<canvas id="categoryChart"></canvas>
</div>
</div>
</div>
<!-- 폐기함 목록 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800">보관된 부적합</h2>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">정렬:</span>
<select id="sortOrder" class="text-sm border border-gray-300 rounded px-2 py-1" onchange="sortIssues()">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
<option value="completed">완료일순</option>
<option value="category">카테고리순</option>
</select>
</div>
</div>
</div>
<div id="issuesList" class="divide-y divide-gray-200">
<!-- 부적합 목록이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 빈 상태 -->
<div id="emptyState" class="hidden p-12 text-center">
<i class="fas fa-archive text-6xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">폐기함이 비어있습니다</h3>
<p class="text-gray-500">완료되거나 폐기된 부적합이 있으면 여기에 표시됩니다.</p>
</div>
</div>
</main>
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=2026031401"></script>
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/core/page-manager.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script src="/static/js/utils/issue-helpers.js?v=2026031401"></script>
<script src="/static/js/utils/photo-modal.js?v=2026031401"></script>
<script src="/static/js/utils/toast.js?v=2026031401"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=2026031401"></script>
<script src="/static/js/pages/issues-archive.js?v=2026031401"></script>
</body>
</html>

View File

@@ -0,0 +1,565 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>부적합 현황판</title>
<script>if(window.innerWidth<=768)window.location.replace('/m/dashboard.html');</script>
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=2026031401">
<link rel="stylesheet" href="/static/css/issues-dashboard.css?v=2026031401">
</head>
<body>
<!-- 로딩 스크린 -->
<div id="loadingScreen" class="fixed inset-0 bg-white z-50 flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p class="text-gray-600">현황판을 불러오는 중...</p>
</div>
</div>
<!-- 로그인 스크린 -->
<div id="loginScreen" class="hidden fixed inset-0 bg-gray-100 z-40 flex items-center justify-center">
<div class="bg-white p-8 rounded-xl shadow-lg max-w-md w-full mx-4">
<h2 class="text-2xl font-bold text-center mb-6 text-gray-800">로그인</h2>
<form id="loginForm">
<div class="mb-4">
<input type="text" id="username" placeholder="사용자명" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="mb-6">
<input type="password" id="password" placeholder="비밀번호" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition-colors">
로그인
</button>
</form>
</div>
</div>
<!-- 메인 콘텐츠 -->
<div id="mainContent" class="min-h-screen">
<!-- 공통 헤더 -->
<div id="commonHeader"></div>
<!-- 메인 콘텐츠 -->
<main class="container mx-auto px-4 py-8 content-fade-in" style="padding-top: 72px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-chart-line text-blue-500 mr-3"></i>
부적합 현황판
</h1>
<p class="text-gray-600 mt-1">진행 중인 부적합 사항을 프로젝트별로 한눈에 확인하세요</p>
</div>
</div>
</div>
<!-- 전체 통계 대시보드 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-blue-500 text-white p-6 rounded-xl dashboard-card">
<div class="flex items-center justify-between">
<div>
<p class="text-blue-100 text-sm flex items-center space-x-1">
<span>전체 진행 중</span>
<div class="w-1.5 h-1.5 bg-blue-200 rounded-full animate-pulse"></div>
</p>
<p class="text-3xl font-bold" id="totalInProgress">0</p>
</div>
<i class="fas fa-tasks text-4xl text-blue-200"></i>
</div>
</div>
<div class="bg-emerald-500 text-white p-6 rounded-xl dashboard-card">
<div class="flex items-center justify-between">
<div>
<p class="text-emerald-100 text-sm flex items-center space-x-1">
<span>오늘 신규</span>
<div class="w-1.5 h-1.5 bg-emerald-200 rounded-full animate-pulse"></div>
</p>
<p class="text-3xl font-bold" id="todayNew">0</p>
</div>
<i class="fas fa-plus-circle text-4xl text-emerald-200"></i>
</div>
</div>
<div class="bg-amber-500 text-white p-6 rounded-xl dashboard-card">
<div class="flex items-center justify-between">
<div>
<p class="text-amber-100 text-sm flex items-center space-x-1">
<span>완료 대기</span>
<div class="w-1.5 h-1.5 bg-amber-200 rounded-full animate-pulse"></div>
</p>
<p class="text-3xl font-bold" id="pendingCompletion">0</p>
</div>
<i class="fas fa-hourglass-half text-4xl text-amber-200"></i>
</div>
</div>
<div class="bg-slate-500 text-white p-6 rounded-xl dashboard-card">
<div class="flex items-center justify-between">
<div>
<p class="text-slate-200 text-sm flex items-center space-x-1">
<span>지연 중</span>
<div class="w-1.5 h-1.5 bg-slate-300 rounded-full animate-pulse"></div>
</p>
<p class="text-3xl font-bold" id="overdue">0</p>
</div>
<i class="fas fa-clock text-4xl text-slate-300"></i>
</div>
</div>
</div>
<!-- 프로젝트 선택 및 필터 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div class="flex items-center space-x-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">프로젝트 선택</label>
<select id="projectFilter" class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-[200px]" onchange="filterByProject()">
<option value="">전체 프로젝트</option>
</select>
</div>
</div>
<div class="flex items-center space-x-2">
<button onclick="refreshDashboard()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-sync-alt mr-2"></i>새로고침
</button>
</div>
</div>
</div>
<!-- 프로젝트별 현황 -->
<div id="projectDashboard" class="space-y-6">
<!-- 동적으로 생성될 프로젝트 카드들 -->
</div>
<!-- 빈 상태 -->
<div id="emptyState" class="hidden text-center py-12">
<i class="fas fa-chart-line text-6xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">진행 중인 부적합이 없습니다</h3>
<p class="text-gray-500">새로운 부적합이 등록되면 여기에 표시됩니다.</p>
</div>
</main>
</div>
<!-- 답글 추가 모달 -->
<div id="replyModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-reply text-blue-500 mr-2"></i>
답글 추가
</h3>
<button onclick="closeReplyModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<form onsubmit="submitReply(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-pen text-blue-500 mr-1"></i>
답글 내용
</label>
<textarea id="replyText" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
placeholder="댓글에 대한 답글을 입력하세요..."
required></textarea>
</div>
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeReplyModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 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-paper-plane mr-2"></i>답글 추가
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 댓글 수정 모달 -->
<div id="editCommentModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-edit text-green-500 mr-2"></i>
댓글 수정
</h3>
<button onclick="closeEditCommentModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<form onsubmit="submitEditComment(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lightbulb text-green-500 mr-1"></i>
댓글 내용
</label>
<textarea id="editCommentText" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none"
placeholder="수정할 댓글 내용을 입력하세요..."
required></textarea>
</div>
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeEditCommentModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-save mr-2"></i>수정 완료
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 답글 수정 모달 -->
<div id="editReplyModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-edit text-green-500 mr-2"></i>
답글 수정
</h3>
<button onclick="closeEditReplyModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<form onsubmit="submitEditReply(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lightbulb text-green-500 mr-1"></i>
답글 내용
</label>
<textarea id="editReplyText" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none"
placeholder="수정할 답글 내용을 입력하세요..."
required></textarea>
</div>
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeEditReplyModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-save mr-2"></i>수정 완료
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 댓글 추가 모달 -->
<div id="commentModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-comment text-blue-500 mr-2"></i>
댓글 추가
</h3>
<button onclick="closeCommentModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<form onsubmit="submitComment(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-pen text-blue-500 mr-1"></i>
댓글 내용
</label>
<textarea id="commentText" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
placeholder="의견에 대한 댓글을 입력하세요..."
required></textarea>
</div>
<!-- 버튼 -->
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeCommentModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 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-paper-plane mr-2"></i>댓글 추가
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 의견 수정 모달 -->
<div id="editOpinionModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-edit text-green-500 mr-2"></i>
의견 수정
</h3>
<button onclick="closeEditOpinionModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<form onsubmit="submitEditOpinion(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lightbulb text-green-500 mr-1"></i>
의견 내용
</label>
<textarea id="editOpinionText" rows="5"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none"
placeholder="수정할 의견 내용을 입력하세요..."
required></textarea>
</div>
<!-- 안내 메시지 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-start">
<i class="fas fa-info-circle text-blue-500 mt-0.5 mr-2"></i>
<div class="text-sm text-blue-700">
<p>댓글은 유지되며 본문만 수정됩니다.</p>
</div>
</div>
</div>
<!-- 버튼 -->
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeEditOpinionModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-save mr-2"></i>수정 완료
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 의견 제시 모달 -->
<div id="opinionModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-comment-medical text-green-500 mr-2"></i>
해결 방안 의견 제시
</h3>
<button onclick="closeOpinionModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<form onsubmit="submitOpinion(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lightbulb text-green-500 mr-1"></i>
의견 내용
</label>
<textarea id="opinionText" rows="5"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none"
placeholder="해결 방안에 대한 의견을 입력하세요..."
required></textarea>
</div>
<!-- 안내 메시지 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-start">
<i class="fas fa-info-circle text-blue-500 mt-0.5 mr-2"></i>
<div class="text-sm text-blue-700">
<p class="font-medium mb-1">의견 제시 안내</p>
<p>입력하신 의견은 <strong>[작성자] (날짜시간)</strong> 형식으로 해결 방안에 추가됩니다.</p>
<p class="mt-1">최신 의견이 맨 위에 표시됩니다.</p>
</div>
</div>
</div>
<!-- 버튼 -->
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeOpinionModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-paper-plane mr-2"></i>의견 제출
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 완료 신청 반려 모달 -->
<div id="rejectionModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-times-circle text-red-500 mr-2"></i>
완료 신청 반려
</h3>
<button onclick="closeRejectionModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<form onsubmit="submitRejection(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-exclamation-triangle text-red-500 mr-1"></i>
반려 사유
</label>
<textarea id="rejectionReason" rows="5"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 resize-none"
placeholder="완료 신청을 반려하는 사유를 입력하세요..."
required></textarea>
</div>
<!-- 안내 메시지 -->
<div class="bg-red-50 border border-red-200 rounded-lg p-3">
<div class="flex items-start">
<i class="fas fa-info-circle text-red-500 mt-0.5 mr-2"></i>
<div class="text-sm text-red-700">
<p class="font-medium mb-1">반려 안내</p>
<p>반려 사유는 담당자에게 전달되며, 해당 이슈는 다시 진행 중 상태로 변경됩니다.</p>
</div>
</div>
</div>
<!-- 버튼 -->
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeRejectionModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-times-circle mr-2"></i>반려하기
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 완료 신청 모달 -->
<div id="completionRequestModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-check-circle text-green-500 mr-2"></i>
완료 신청
</h3>
<button onclick="closeCompletionRequestModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<form id="completionRequestForm" class="space-y-4">
<!-- 완료 사진 업로드 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-camera text-green-500 mr-1"></i>
완료 사진 (필수)
</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-green-400 transition-colors">
<input type="file" id="completionPhoto" accept="image/*" capture="environment" class="hidden" onchange="handleCompletionPhotoUpload(event)">
<div id="photoUploadArea" onclick="document.getElementById('completionPhoto').click()" class="cursor-pointer">
<i class="fas fa-cloud-upload-alt text-gray-400 text-2xl mb-2"></i>
<p class="text-sm text-gray-600">클릭하여 완료 사진을 업로드하세요</p>
<p class="text-xs text-gray-500 mt-1">JPG, PNG 파일만 가능</p>
</div>
<div id="photoPreview" class="hidden mt-3">
<img id="previewImage" class="max-w-full h-32 object-cover rounded-lg mx-auto">
<p class="text-sm text-green-600 mt-2">✓ 사진이 업로드되었습니다</p>
</div>
</div>
</div>
<!-- 완료 코멘트 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-comment text-blue-500 mr-1"></i>
완료 코멘트 (선택사항)
</label>
<textarea id="completionComment" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none"
placeholder="완료 상황에 대한 간단한 설명을 입력하세요..."></textarea>
</div>
<!-- 안내 메시지 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-3">
<div class="flex items-start">
<i class="fas fa-info-circle text-green-500 mt-0.5 mr-2"></i>
<div class="text-sm text-green-700">
<p class="font-medium mb-1">완료 신청 안내</p>
<p>완료 사진과 함께 신청하시면 관리자 승인 후 완료 처리됩니다.</p>
</div>
</div>
</div>
<!-- 버튼 -->
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeCompletionRequestModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-paper-plane mr-2"></i>완료 신청
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/core/page-manager.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script src="/static/js/utils/issue-helpers.js?v=2026031401"></script>
<script src="/static/js/utils/photo-modal.js?v=2026031401"></script>
<script src="/static/js/utils/toast.js?v=2026031401"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=2026031401"></script>
<script src="/static/js/pages/issues-dashboard.js?v=2026031401"></script>
</body>
</html>

View File

@@ -0,0 +1,385 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>수신함 - 작업보고서</title>
<script>if(window.innerWidth<=768)window.location.replace('/m/inbox.html');</script>
<!-- Tailwind CSS -->
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- 모바일 캘린더 스타일 -->
<link rel="stylesheet" href="/static/css/mobile-calendar.css">
<!-- 공통 스타일 -->
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=2026031401">
<!-- 페이지 전용 스타일 -->
<link rel="stylesheet" href="/static/css/issues-inbox.css?v=2026031401">
</head>
<body>
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="loading-overlay">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p class="text-gray-600">데이터를 불러오는 중...</p>
</div>
</div>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8 content-fade-in" style="padding-top: 72px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-inbox text-blue-500 mr-3"></i>
수신함
</h1>
<p class="text-gray-600 mt-1">새로 등록된 신고 사항을 확인하고 처리하세요</p>
</div>
<div class="flex items-center space-x-3">
<button onclick="refreshInbox()" class="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors">
<i class="fas fa-sync-alt mr-2"></i>
새로고침
</button>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-yellow-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-plus-circle text-yellow-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-yellow-600">금일 신규</p>
<p class="text-2xl font-bold text-yellow-700" id="todayNewCount">0</p>
</div>
</div>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-green-600">금일 처리</p>
<p class="text-2xl font-bold text-green-700" id="todayProcessedCount">0</p>
</div>
</div>
</div>
<div class="bg-red-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-red-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-red-600">미해결</p>
<p class="text-2xl font-bold text-red-700" id="unresolvedCount">0</p>
</div>
</div>
</div>
</div>
</div>
<!-- 필터 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="max-w-md">
<!-- 프로젝트 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📁 프로젝트</label>
<select id="projectFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
</div>
</div>
<!-- 신고 목록 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800">신고 목록</h2>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">정렬:</span>
<select id="sortOrder" class="text-sm border border-gray-300 rounded px-2 py-1" onchange="sortIssues()">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
<option value="priority">우선순위</option>
<option value="unread">읽지 않은 순</option>
</select>
</div>
</div>
</div>
<div id="issuesList" class="divide-y divide-gray-200">
<!-- 부적합 목록이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 빈 상태 -->
<div id="emptyState" class="hidden p-12 text-center">
<i class="fas fa-inbox text-6xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">수신함이 비어있습니다</h3>
<p class="text-gray-500">새로운 신고가 등록되면 여기에 표시됩니다.</p>
</div>
</div>
</main>
<!-- 폐기 모달 -->
<div id="disposeModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">부적합 폐기</h3>
<button onclick="closeDisposeModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">폐기 사유</label>
<select id="disposalReason" class="w-full px-3 py-2 border border-gray-300 rounded-lg" onchange="toggleCustomReason(); toggleDuplicateSelection();">
<option value="duplicate">중복 (기본)</option>
<option value="invalid_report">잘못된 신고</option>
<option value="not_applicable">해당 없음</option>
<option value="spam">스팸/오류</option>
<option value="custom">직접 입력</option>
</select>
</div>
<div id="customReasonDiv" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-2">사용자 정의 사유</label>
<textarea id="customReason" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="폐기 사유를 입력하세요..."></textarea>
</div>
<!-- 중복 대상 선택 -->
<div id="duplicateSelectionDiv" class="space-y-3">
<label class="block text-sm font-medium text-gray-700 mb-2">중복 대상 선택</label>
<p class="text-sm text-gray-600 mb-3">동일 프로젝트의 관리함에 있는 이슈 중 중복 대상을 선택하세요:</p>
<div id="managementIssuesList" class="max-h-48 overflow-y-auto border border-gray-200 rounded-lg">
<div class="p-4 text-center text-gray-500">
<i class="fas fa-spinner fa-spin mr-2"></i>관리함 이슈를 불러오는 중...
</div>
</div>
<input type="hidden" id="selectedDuplicateId" value="">
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeDisposeModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
</button>
<button onclick="confirmDispose()" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
폐기
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 검토/수정 모달 -->
<div id="reviewModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">부적합 검토 및 수정</h3>
<button onclick="closeReviewModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div class="space-y-4">
<!-- 원본 정보 표시 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium text-gray-700 mb-2">원본 정보</h4>
<div id="originalInfo" class="text-sm text-gray-600">
<!-- 원본 정보가 여기에 표시됩니다 -->
</div>
</div>
<!-- AI 분류 추천 -->
<div class="bg-purple-50 border border-purple-200 rounded-lg p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-purple-700">
<i class="fas fa-robot mr-1"></i>AI 분류 추천
</span>
<button id="aiClassifyBtn" onclick="aiClassifyCurrentIssue()"
class="px-3 py-1 bg-purple-500 text-white text-xs rounded-lg hover:bg-purple-600 transition-colors">
<i class="fas fa-magic mr-1"></i>AI 분석
</button>
</div>
<div id="aiClassifyLoading" class="hidden mt-2 text-center">
<i class="fas fa-spinner fa-spin text-purple-500 mr-1"></i>
<span class="text-xs text-purple-600">AI 분석 중...</span>
</div>
<div id="aiClassifyResult" class="hidden mt-2 text-sm text-purple-800 space-y-1">
<!-- AI 결과가 여기에 표시됩니다 -->
</div>
</div>
<!-- 수정 폼 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">프로젝트</label>
<select id="reviewProjectId" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="">프로젝트 선택</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
<select id="reviewCategory" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="material_missing">자재 누락</option>
<option value="design_error">설계 오류</option>
<option value="incoming_defect">반입 불량</option>
<option value="inspection_miss">검사 누락</option>
<option value="etc">기타</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">부적합명</label>
<input type="text" id="reviewTitle" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="부적합의 간단한 제목을 입력하세요...">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">상세 내용</label>
<textarea id="reviewDescription" rows="4" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="부적합에 대한 상세한 설명을 입력하세요..."></textarea>
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeReviewModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
</button>
<button onclick="saveReview()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
검토 완료
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 상태 결정 모달 -->
<div id="statusModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">최종 상태 결정</h3>
<button onclick="closeStatusModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">상태 선택</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="radio" name="finalStatus" value="in_progress" class="mr-2" onchange="toggleCompletionPhotoSection()">
<span class="text-sm">🔄 진행 중 (관리함으로 이동)</span>
</label>
<label class="flex items-center">
<input type="radio" name="finalStatus" value="completed" class="mr-2" onchange="toggleCompletionPhotoSection()">
<span class="text-sm">✅ 완료됨 (관리함으로 이동)</span>
</label>
</div>
</div>
<!-- 완료 관련 추가 정보 (완료 상태 선택 시에만 표시) -->
<div id="completionSection" class="hidden space-y-4">
<!-- 완료 사진 업로드 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-camera text-green-500 mr-1"></i>완료 사진 (1장, 선택사항)
</label>
<input type="file" id="completionPhotoInput" accept="image/*" capture="environment"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
onchange="handleCompletionPhotoSelect(event)">
<div id="completionPhotoPreview" class="mt-2 hidden">
<img id="completionPhotoImg" src="" alt="완료 사진 미리보기"
class="w-full max-h-40 object-cover rounded-lg border">
</div>
</div>
<!-- 해결방안 입력 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lightbulb text-yellow-500 mr-1"></i>해결방안 (선택사항)
</label>
<textarea id="solutionInput" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="어떻게 해결하였는지 입력하세요... (빈칸으로 두고 관리함에서 입력해도 됩니다)"></textarea>
</div>
<!-- 해결한 부서 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-building text-blue-500 mr-1"></i>상기 문제를 해결한 부서 (선택사항)
</label>
<select id="responsibleDepartmentInput" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">부서 선택 (관리함에서 입력 가능)</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<!-- 해결한 사람 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-user text-purple-500 mr-1"></i>해결한 사람 (선택사항)
</label>
<input type="text" id="responsiblePersonInput"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="담당자 이름 입력 (관리함에서 입력 가능)">
</div>
<div class="bg-blue-50 p-3 rounded-lg">
<p class="text-xs text-blue-600">
<i class="fas fa-info-circle mr-1"></i>
위 정보들은 선택사항입니다. 빈칸으로 두고 관리함에서 나중에 입력하셔도 됩니다.
</p>
</div>
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeStatusModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
</button>
<button onclick="confirmStatus()" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
상태 변경
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=2026031401"></script>
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/core/page-manager.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script src="/static/js/components/mobile-calendar.js?v=2026031401"></script>
<script src="/static/js/utils/issue-helpers.js?v=2026031401"></script>
<script src="/static/js/utils/photo-modal.js?v=2026031401"></script>
<script src="/static/js/utils/toast.js?v=2026031401"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=2026031401"></script>
<script src="/static/js/pages/issues-inbox.js?v=2026031401"></script>
</body>
</html>

View File

@@ -0,0 +1,354 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리함 - 작업보고서</title>
<script>if(window.innerWidth<=768)window.location.replace('/m/management.html');</script>
<!-- Tailwind CSS -->
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- 모바일 캘린더 스타일 -->
<link rel="stylesheet" href="/static/css/mobile-calendar.css">
<!-- 공통 스타일 및 페이지 전용 스타일 -->
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=2026031401">
<link rel="stylesheet" href="/static/css/issues-management.css?v=2026031401">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="/static/js/lib/purify.min.js"></script>
</head>
<body>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 72px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-cog text-green-500 mr-3"></i>
관리함
</h1>
<p class="text-gray-600 mt-1">부적합 사항을 처리하고 상태를 관리하세요</p>
</div>
</div>
</div>
<!-- 프로젝트 필터 및 상태 탭 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="space-y-4">
<!-- 프로젝트 선택 -->
<div class="max-w-md">
<label class="block text-sm font-medium text-gray-700 mb-2">📁 프로젝트</label>
<select id="projectFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 상태 탭 및 추가 정보 버튼 -->
<div class="flex items-center justify-between">
<div class="flex space-x-1 bg-gray-100 p-1 rounded-lg max-w-md">
<button id="inProgressTab"
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors duration-200 bg-blue-500 text-white"
onclick="switchTab('in_progress')">
<i class="fas fa-cog mr-2"></i>진행 중
</button>
<button id="completedTab"
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors duration-200 text-gray-600 hover:text-gray-900"
onclick="switchTab('completed')">
<i class="fas fa-check-circle mr-2"></i>완료됨
</button>
</div>
<!-- 추가 정보 입력 버튼 (진행 중 탭에서만 표시) -->
<button id="additionalInfoBtn"
class="px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors duration-200 shadow-sm"
onclick="openAdditionalInfoModal()"
style="display: none;">
<i class="fas fa-plus-circle mr-2"></i>추가 정보 입력
</button>
</div>
<!-- 프로젝트별 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-chart-bar text-gray-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-gray-600">총 부적합</p>
<p class="text-2xl font-bold text-gray-700" id="totalCount">0</p>
</div>
</div>
</div>
<div class="bg-blue-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-cog text-blue-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-blue-600">진행 중</p>
<p class="text-2xl font-bold text-blue-700" id="inProgressCount">0</p>
</div>
</div>
</div>
<div class="bg-purple-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-hourglass-half text-purple-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-purple-600">완료 대기</p>
<p class="text-2xl font-bold text-purple-700" id="pendingCompletionCount">0</p>
</div>
</div>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-green-600">완료됨</p>
<p class="text-2xl font-bold text-green-700" id="completedCount">0</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 부적합 관리 목록 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800">부적합 관리</h2>
<div class="flex items-center space-x-4">
<select id="sortOrder" class="text-sm border border-gray-300 rounded px-2 py-1" onchange="sortIssues()">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
</select>
</div>
</div>
</div>
<div id="issuesList" class="p-4">
<!-- 날짜별 그룹화된 부적합 목록이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 빈 상태 -->
<div id="emptyState" class="hidden p-12 text-center">
<i class="fas fa-cog text-6xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">관리할 부적합이 없습니다</h3>
<p class="text-gray-500">처리가 필요한 부적합이 있으면 여기에 표시됩니다.</p>
</div>
</div>
</main>
<!-- 완료된 이슈 상세보기 모달 -->
<div id="issueDetailModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900" id="modalTitle">부적합 상세 정보</h2>
<button onclick="closeIssueDetailModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<div id="modalContent" class="space-y-6">
<!-- 동적으로 생성될 내용 -->
</div>
<!-- AI 유사 부적합 패널 -->
<div id="aiSimilarPanel" class="mt-6 border-t pt-6 hidden">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700">
<i class="fas fa-robot text-purple-500 mr-2"></i>AI 유사 부적합
</h3>
<button id="aiSimilarRefresh" onclick="loadSimilarIssues()" class="text-xs text-purple-500 hover:text-purple-700">
<i class="fas fa-sync-alt mr-1"></i>검색
</button>
</div>
<div id="aiSimilarLoading" class="hidden text-center py-4">
<i class="fas fa-spinner fa-spin text-purple-500 mr-2"></i>
<span class="text-sm text-gray-500">유사 이슈 검색 중...</span>
</div>
<div id="aiSimilarResults" class="space-y-2">
<!-- 유사 이슈 목록 -->
</div>
<div id="aiSimilarEmpty" class="hidden text-center py-3">
<p class="text-sm text-gray-400">유사한 부적합이 없습니다</p>
</div>
<!-- RAG 해결방안 제안 -->
<div class="mt-4 pt-3 border-t border-purple-100">
<button id="aiSuggestSolutionBtn" onclick="aiSuggestSolution()"
class="w-full px-3 py-2 bg-gradient-to-r from-purple-500 to-indigo-500 text-white text-sm rounded-lg hover:from-purple-600 hover:to-indigo-600 transition-all">
<i class="fas fa-lightbulb mr-2"></i>AI 해결방안 제안 (과거 사례 기반)
</button>
<div id="aiSuggestLoading" class="hidden mt-2 text-center py-3">
<i class="fas fa-spinner fa-spin text-purple-500 mr-1"></i>
<span class="text-xs text-gray-500">과거 사례 분석 중...</span>
</div>
<div id="aiSuggestResult" class="hidden mt-2 bg-indigo-50 border border-indigo-200 rounded-lg p-3">
<div id="aiSuggestContent" class="text-sm text-gray-700 prose prose-sm max-w-none"></div>
<div id="aiSuggestSources" class="mt-2 text-xs text-indigo-500"></div>
</div>
</div>
</div>
<!-- 모달 푸터 -->
<div class="flex justify-end space-x-3 mt-6 pt-6 border-t">
<button onclick="closeIssueDetailModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
</button>
<button onclick="saveModalChanges()" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
저장
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 상태 변경 모달 -->
<div id="statusModal" class="fixed inset-0 bg-black bg-opacity-50 modal hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">상태 변경</h3>
<button onclick="closeStatusModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">새 상태</label>
<select id="newStatus" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="processing">처리 중</option>
<option value="pending">대기 중</option>
<option value="completed">완료</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">처리 메모</label>
<textarea id="statusNote" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="상태 변경 사유나 처리 내용을 입력하세요..."></textarea>
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeStatusModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
</button>
<button onclick="updateStatus()" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
변경
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 추가 정보 입력 모달 -->
<div id="additionalInfoModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-info-circle text-orange-500 mr-2"></i>
추가 정보 입력
</h3>
<button onclick="closeAdditionalInfoModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<form id="additionalInfoForm" class="space-y-4">
<!-- 원인부서 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-building text-gray-500 mr-1"></i>
원인부서
</label>
<select id="causeDepartment" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500">
<option value="">선택하세요</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<!-- 해당자 상세 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-user text-gray-500 mr-1"></i>
해당자 상세
</label>
<input type="text" id="responsiblePersonDetail"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
placeholder="해당자 이름, 직책 등 상세 정보">
</div>
<!-- 원인 상세 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-clipboard-list text-gray-500 mr-1"></i>
원인 상세
</label>
<textarea id="causeDetail" rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 resize-none"
placeholder="원인에 대한 상세한 설명을 입력하세요"></textarea>
</div>
<!-- 안내 메시지 -->
<div class="bg-orange-50 border border-orange-200 rounded-lg p-3">
<div class="flex items-start">
<i class="fas fa-info-circle text-orange-500 mt-0.5 mr-2"></i>
<div class="text-sm text-orange-700">
<p class="font-medium mb-1">기록용 정보</p>
<p>이 정보는 내부 기록용으로만 사용되며, 모든 필드는 선택사항입니다.</p>
</div>
</div>
</div>
<!-- 버튼 -->
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeAdditionalInfoModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors">
<i class="fas fa-save mr-2"></i>저장
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=2026031401"></script>
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/core/page-manager.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script src="/static/js/utils/issue-helpers.js?v=2026031401"></script>
<script src="/static/js/utils/photo-modal.js?v=2026031401"></script>
<script src="/static/js/utils/toast.js?v=2026031401"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=2026031401"></script>
<script src="/static/js/pages/issues-management.js?v=2026040902"></script>
</body>
</html>

View File

@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>부적합 현황판</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/m-common.css?v=2026031401">
</head>
<body>
<!-- 로딩 -->
<div id="loadingOverlay" class="m-loading-overlay">
<div class="m-spinner"></div>
<p>현황판을 불러오는 중...</p>
</div>
<!-- 고정 상단 헤더 -->
<header class="m-header">
<div class="m-header-title">
<i class="fas fa-chart-line" style="color:#3b82f6"></i>
현황판
</div>
<div class="m-header-actions">
<button class="m-header-btn" onclick="refreshPage()"><i class="fas fa-sync-alt"></i></button>
</div>
</header>
<!-- 통계 바 -->
<div class="m-stats-bar" id="statsBar">
<div class="m-stat-pill blue"><span>전체</span><span class="m-stat-value" id="totalInProgress">0</span></div>
<div class="m-stat-pill green"><span>오늘 신규</span><span class="m-stat-value" id="todayNew">0</span></div>
<div class="m-stat-pill purple"><span>완료 대기</span><span class="m-stat-value" id="pendingCompletion">0</span></div>
<div class="m-stat-pill red"><span>지연</span><span class="m-stat-value" id="overdue">0</span></div>
</div>
<!-- 프로젝트 필터 -->
<div class="m-filter-bar">
<select class="m-filter-select" id="projectFilter" onchange="filterByProject()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 이슈 카드 리스트 -->
<div id="issuesList"></div>
<!-- 빈 상태 -->
<div id="emptyState" class="m-empty hidden">
<i class="fas fa-chart-line"></i>
<p>진행 중인 부적합이 없습니다</p>
</div>
<!-- ===== 바텀시트: 의견 제시 ===== -->
<div id="opinionOverlay" class="m-sheet-overlay" onclick="closeSheet('opinion')"></div>
<div id="opinionSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-comment-medical" style="color:#22c55e;margin-right:6px"></i>의견 제시</span>
<button class="m-sheet-close" onclick="closeSheet('opinion')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">의견 내용</label>
<textarea id="opinionText" class="m-textarea" rows="4" placeholder="해결 방안에 대한 의견을 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn green" onclick="submitOpinion()">
<i class="fas fa-paper-plane"></i>의견 제출
</button>
</div>
</div>
<!-- ===== 바텀시트: 댓글 추가 ===== -->
<div id="commentOverlay" class="m-sheet-overlay" onclick="closeSheet('comment')"></div>
<div id="commentSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-comment" style="color:#3b82f6;margin-right:6px"></i>댓글 추가</span>
<button class="m-sheet-close" onclick="closeSheet('comment')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">댓글 내용</label>
<textarea id="commentText" class="m-textarea" rows="3" placeholder="의견에 대한 댓글을 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="submitComment()">
<i class="fas fa-paper-plane"></i>댓글 추가
</button>
</div>
</div>
<!-- ===== 바텀시트: 답글 추가 ===== -->
<div id="replyOverlay" class="m-sheet-overlay" onclick="closeSheet('reply')"></div>
<div id="replySheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-reply" style="color:#3b82f6;margin-right:6px"></i>답글 추가</span>
<button class="m-sheet-close" onclick="closeSheet('reply')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">답글 내용</label>
<textarea id="replyText" class="m-textarea" rows="3" placeholder="댓글에 대한 답글을 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="submitReply()">
<i class="fas fa-paper-plane"></i>답글 추가
</button>
</div>
</div>
<!-- ===== 바텀시트: 의견/댓글/답글 수정 ===== -->
<div id="editOverlay" class="m-sheet-overlay" onclick="closeSheet('edit')"></div>
<div id="editSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title" id="editSheetTitle"><i class="fas fa-edit" style="color:#22c55e;margin-right:6px"></i>수정</span>
<button class="m-sheet-close" onclick="closeSheet('edit')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">내용</label>
<textarea id="editText" class="m-textarea" rows="4"></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn green" onclick="submitEdit()">
<i class="fas fa-save"></i>수정 완료
</button>
</div>
</div>
<!-- ===== 바텀시트: 완료 신청 ===== -->
<div id="completionOverlay" class="m-sheet-overlay" onclick="closeSheet('completion')"></div>
<div id="completionSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-check-circle" style="color:#22c55e;margin-right:6px"></i>완료 신청</span>
<button class="m-sheet-close" onclick="closeSheet('completion')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label"><i class="fas fa-camera" style="color:#22c55e;margin-right:4px"></i>완료 사진 (필수)</label>
<div class="m-photo-upload" onclick="document.getElementById('completionPhotoInput').click()">
<i class="fas fa-cloud-upload-alt"></i>
<p>탭하여 완료 사진 업로드</p>
</div>
<input type="file" id="completionPhotoInput" accept="image/*" capture="environment" class="hidden" onchange="handleCompletionPhoto(event)">
<img id="completionPhotoPreview" class="m-photo-preview hidden">
</div>
<div class="m-form-group">
<label class="m-label">완료 코멘트 (선택)</label>
<textarea id="completionComment" class="m-textarea" rows="3" placeholder="완료 상황에 대한 간단한 설명..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn green" onclick="submitCompletionRequest()">
<i class="fas fa-paper-plane"></i>완료 신청
</button>
</div>
</div>
<!-- ===== 바텀시트: 완료 반려 ===== -->
<div id="rejectionOverlay" class="m-sheet-overlay" onclick="closeSheet('rejection')"></div>
<div id="rejectionSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-times-circle" style="color:#ef4444;margin-right:6px"></i>완료 반려</span>
<button class="m-sheet-close" onclick="closeSheet('rejection')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">반려 사유</label>
<textarea id="rejectionReason" class="m-textarea" rows="4" placeholder="완료 신청을 반려하는 사유를 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn red" onclick="submitRejection()">
<i class="fas fa-times-circle"></i>반려하기
</button>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/utils/issue-helpers.js?v=2026031401"></script>
<script src="/static/js/m/m-common.js?v=2026031401"></script>
<script src="/static/js/m/m-dashboard.js?v=2026031401"></script>
</body>
</html>

View File

@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>수신함</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/m-common.css?v=2026031401">
</head>
<body>
<!-- 로딩 -->
<div id="loadingOverlay" class="m-loading-overlay">
<div class="m-spinner"></div>
<p>수신함을 불러오는 중...</p>
</div>
<!-- 고정 상단 헤더 -->
<header class="m-header">
<div class="m-header-title">
<i class="fas fa-inbox" style="color:#3b82f6"></i>
수신함
</div>
<div class="m-header-actions">
<button class="m-header-btn" onclick="location.reload()"><i class="fas fa-sync-alt"></i></button>
</div>
</header>
<!-- 통계 바 -->
<div class="m-stats-bar" id="statsBar">
<div class="m-stat-pill green"><span>금일 신규</span><span class="m-stat-value" id="todayNewCount">0</span></div>
<div class="m-stat-pill blue"><span>금일 처리</span><span class="m-stat-value" id="todayProcessedCount">0</span></div>
<div class="m-stat-pill red"><span>미해결</span><span class="m-stat-value" id="unresolvedCount">0</span></div>
</div>
<!-- 프로젝트 필터 -->
<div class="m-filter-bar">
<select class="m-filter-select" id="projectFilter" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 이슈 카드 리스트 -->
<div id="issuesList"></div>
<!-- 빈 상태 -->
<div id="emptyState" class="m-empty hidden">
<i class="fas fa-inbox"></i>
<p>검토 대기 중인 부적합이 없습니다</p>
</div>
<!-- ===== 바텀시트: 폐기 ===== -->
<div id="disposeOverlay" class="m-sheet-overlay" onclick="closeSheet('dispose')"></div>
<div id="disposeSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-trash" style="color:#ef4444;margin-right:6px"></i>폐기</span>
<button class="m-sheet-close" onclick="closeSheet('dispose')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">폐기 사유</label>
<select id="disposalReason" class="m-select" onchange="toggleDisposalFields()">
<option value="duplicate">중복</option>
<option value="invalid_report">잘못된 신고</option>
<option value="not_applicable">해당 없음</option>
<option value="spam">스팸/오류</option>
<option value="custom">직접 입력</option>
</select>
</div>
<div id="customReasonDiv" class="m-form-group hidden">
<label class="m-label">사용자 정의 사유</label>
<textarea id="customReason" class="m-textarea" rows="2" placeholder="폐기 사유를 입력하세요..."></textarea>
</div>
<div id="duplicateDiv" class="m-form-group">
<label class="m-label">중복 대상 선택</label>
<div id="managementIssuesList" style="max-height:200px;overflow-y:auto;border:1px solid #e5e7eb;border-radius:10px">
<div class="m-loading"><div class="m-spinner"></div></div>
</div>
<input type="hidden" id="selectedDuplicateId">
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn red" onclick="confirmDispose()">
<i class="fas fa-trash"></i>폐기 확인
</button>
</div>
</div>
<!-- ===== 바텀시트: 검토 ===== -->
<div id="reviewOverlay" class="m-sheet-overlay" onclick="closeSheet('review')"></div>
<div id="reviewSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-edit" style="color:#3b82f6;margin-right:6px"></i>검토</span>
<button class="m-sheet-close" onclick="closeSheet('review')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div id="originalInfo" style="background:#f3f4f6;border-radius:10px;padding:12px;margin-bottom:16px;font-size:13px;color:#6b7280"></div>
<div class="m-form-group">
<label class="m-label">프로젝트</label>
<select id="reviewProjectId" class="m-select"></select>
</div>
<div class="m-form-group">
<label class="m-label">분류</label>
<select id="reviewCategory" class="m-select">
<option value="material_missing">자재 누락</option>
<option value="design_error">설계 오류</option>
<option value="incoming_defect">반입 불량</option>
<option value="inspection_miss">검사 누락</option>
<option value="quality">품질</option>
<option value="safety">안전</option>
<option value="environment">환경</option>
<option value="process">공정</option>
<option value="equipment">장비</option>
<option value="material">자재</option>
<option value="etc">기타</option>
</select>
</div>
<div class="m-form-group">
<label class="m-label">부적합명</label>
<input type="text" id="reviewTitle" class="m-input" placeholder="부적합명을 입력하세요">
</div>
<div class="m-form-group">
<label class="m-label">상세 설명</label>
<textarea id="reviewDescription" class="m-textarea" rows="3" placeholder="상세 설명을 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="saveReview()">
<i class="fas fa-save"></i>검토 완료
</button>
</div>
</div>
<!-- ===== 바텀시트: 확인 (상태 결정) ===== -->
<div id="statusOverlay" class="m-sheet-overlay" onclick="closeSheet('status')"></div>
<div id="statusSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-check" style="color:#22c55e;margin-right:6px"></i>확인</span>
<button class="m-sheet-close" onclick="closeSheet('status')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">상태 결정</label>
<div class="m-radio-group" id="statusRadioGroup">
<label class="m-radio-item" onclick="selectStatus('in_progress')">
<input type="radio" name="finalStatus" value="in_progress">
<div><div style="font-weight:600;font-size:14px">진행 중</div><div style="font-size:12px;color:#6b7280">관리함으로 이동하여 조치합니다</div></div>
</label>
<label class="m-radio-item" onclick="selectStatus('completed')">
<input type="radio" name="finalStatus" value="completed">
<div><div style="font-weight:600;font-size:14px">즉시 완료</div><div style="font-size:12px;color:#6b7280">바로 완료 처리합니다</div></div>
</label>
</div>
</div>
<div id="completionSection" class="hidden">
<div class="m-form-group">
<label class="m-label">해결방안</label>
<textarea id="solutionInput" class="m-textarea" rows="2" placeholder="해결방안을 입력하세요..."></textarea>
</div>
<div class="m-form-group">
<label class="m-label">담당부서</label>
<select id="responsibleDepartmentInput" class="m-select">
<option value="">선택하세요</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div class="m-form-group">
<label class="m-label">담당자</label>
<input type="text" id="responsiblePersonInput" class="m-input" placeholder="담당자 이름">
</div>
<div class="m-form-group">
<label class="m-label">완료 사진</label>
<div class="m-photo-upload" onclick="document.getElementById('statusPhotoInput').click()">
<i class="fas fa-cloud-upload-alt"></i>
<p>탭하여 사진 업로드 (선택)</p>
</div>
<input type="file" id="statusPhotoInput" accept="image/*" capture="environment" class="hidden" onchange="handleStatusPhoto(event)">
<img id="statusPhotoPreview" class="m-photo-preview hidden">
</div>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn green" onclick="confirmStatus()">
<i class="fas fa-check-circle"></i>확인
</button>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/utils/issue-helpers.js?v=2026031401"></script>
<script src="/static/js/m/m-common.js?v=2026031401"></script>
<script src="/static/js/m/m-inbox.js?v=2026031401"></script>
</body>
</html>

View File

@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>관리함</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/m-common.css?v=2026031401">
</head>
<body>
<!-- 로딩 -->
<div id="loadingOverlay" class="m-loading-overlay">
<div class="m-spinner"></div>
<p>관리함을 불러오는 중...</p>
</div>
<!-- 고정 상단 헤더 -->
<header class="m-header">
<div class="m-header-title">
<i class="fas fa-tasks" style="color:#3b82f6"></i>
관리함
</div>
<div class="m-header-actions">
<button class="m-header-btn" id="additionalInfoBtn" onclick="openAdditionalInfoSheet()" style="display:none"><i class="fas fa-info-circle"></i></button>
<button class="m-header-btn" onclick="location.reload()"><i class="fas fa-sync-alt"></i></button>
</div>
</header>
<!-- 탭 바 -->
<div class="m-tab-bar" style="margin-top:48px;position:sticky;top:48px;z-index:50;background:#fff">
<button class="m-tab active" id="tabInProgress" onclick="switchTab('in_progress')">진행 중</button>
<button class="m-tab" id="tabCompleted" onclick="switchTab('completed')">완료됨</button>
</div>
<!-- 통계 바 -->
<div class="m-stats-bar" id="statsBar">
<div class="m-stat-pill blue"><span>전체</span><span class="m-stat-value" id="totalCount">0</span></div>
<div class="m-stat-pill amber"><span>진행 중</span><span class="m-stat-value" id="inProgressCount">0</span></div>
<div class="m-stat-pill purple"><span>완료 대기</span><span class="m-stat-value" id="pendingCompletionCount">0</span></div>
<div class="m-stat-pill green"><span>완료됨</span><span class="m-stat-value" id="completedCount">0</span></div>
</div>
<!-- 프로젝트 필터 -->
<div class="m-filter-bar">
<select class="m-filter-select" id="projectFilter" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 이슈 카드 리스트 -->
<div id="issuesList"></div>
<!-- 빈 상태 -->
<div id="emptyState" class="m-empty hidden">
<i class="fas fa-tasks"></i>
<p>해당하는 부적합이 없습니다</p>
</div>
<!-- ===== 바텀시트: 편집 (관리 필드) ===== -->
<div id="editMgmtOverlay" class="m-sheet-overlay" onclick="closeSheet('editMgmt')"></div>
<div id="editMgmtSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-edit" style="color:#3b82f6;margin-right:6px"></i>관리 정보 편집</span>
<button class="m-sheet-close" onclick="closeSheet('editMgmt')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label"><i class="fas fa-folder" style="color:#8b5cf6;margin-right:4px"></i>프로젝트</label>
<select id="editProject" class="m-select"><option value="">선택하세요</option></select>
</div>
<div class="m-form-group">
<label class="m-label"><i class="fas fa-lightbulb" style="color:#eab308;margin-right:4px"></i>해결방안 (확정)</label>
<textarea id="editManagementComment" class="m-textarea" rows="3" placeholder="확정된 해결 방안을 입력하세요..."></textarea>
</div>
<div class="m-form-group">
<label class="m-label"><i class="fas fa-building" style="color:#3b82f6;margin-right:4px"></i>담당부서</label>
<select id="editResponsibleDept" class="m-select">
<option value="">선택하세요</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div class="m-form-group">
<label class="m-label"><i class="fas fa-user" style="color:#8b5cf6;margin-right:4px"></i>담당자</label>
<input type="text" id="editResponsiblePerson" class="m-input" placeholder="담당자 이름">
</div>
<div class="m-form-group">
<label class="m-label"><i class="fas fa-calendar-alt" style="color:#ef4444;margin-right:4px"></i>조치 예상일</label>
<input type="date" id="editExpectedDate" class="m-input">
</div>
<!-- 원본 사진 보충 (빈 슬롯에만 채움) -->
<div class="m-form-group" id="editPhotoGroup">
<label class="m-label"><i class="fas fa-camera" style="color:#10b981;margin-right:4px"></i>사진 보충 <span id="editPhotoSlotInfo" style="font-size:11px;color:#6b7280"></span></label>
<div id="editExistingPhotos" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px"></div>
<input type="file" id="editPhotoInput" accept="image/*" multiple
class="m-input" style="padding:8px;font-size:12px" onchange="previewEditPhotos(event)">
<div id="editPhotoPreview" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px"></div>
<p style="font-size:11px;color:#9ca3af;margin-top:4px">※ 비어있는 슬롯에만 자동 채움. 기존 사진은 유지됩니다.</p>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="saveManagementEdit()">
<i class="fas fa-save"></i>저장
</button>
</div>
</div>
<!-- ===== 바텀시트: 추가 정보 ===== -->
<div id="additionalOverlay" class="m-sheet-overlay" onclick="closeSheet('additional')"></div>
<div id="additionalSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-info-circle" style="color:#f59e0b;margin-right:6px"></i>추가 정보 입력</span>
<button class="m-sheet-close" onclick="closeSheet('additional')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">대상 이슈 선택</label>
<select id="additionalIssueSelect" class="m-select" onchange="loadAdditionalInfo()"></select>
</div>
<div class="m-form-group">
<label class="m-label">원인부서</label>
<select id="additionalCauseDept" class="m-select">
<option value="">선택하세요</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div class="m-form-group">
<label class="m-label">해당자</label>
<input type="text" id="additionalCausePerson" class="m-input" placeholder="해당자 이름">
</div>
<div class="m-form-group">
<label class="m-label">원인 상세</label>
<textarea id="additionalCauseDetail" class="m-textarea" rows="3" placeholder="원인을 상세히 기술하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="saveAdditionalInfo()">
<i class="fas fa-save"></i>저장
</button>
</div>
</div>
<!-- ===== 바텀시트: 완료됨 상세보기 ===== -->
<div id="detailOverlay" class="m-sheet-overlay" onclick="closeSheet('detail')"></div>
<div id="detailSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title" id="detailSheetTitle">상세 정보</span>
<button class="m-sheet-close" onclick="closeSheet('detail')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body" id="detailSheetBody"></div>
</div>
<!-- ===== 바텀시트: 반려 ===== -->
<div id="rejectOverlay" class="m-sheet-overlay" onclick="closeSheet('reject')"></div>
<div id="rejectSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-times-circle" style="color:#ef4444;margin-right:6px"></i>완료 반려</span>
<button class="m-sheet-close" onclick="closeSheet('reject')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">반려 사유</label>
<textarea id="rejectReason" class="m-textarea" rows="4" placeholder="반려 사유를 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn red" onclick="submitReject()">
<i class="fas fa-times-circle"></i>반려하기
</button>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/utils/issue-helpers.js?v=2026031401"></script>
<script src="/static/js/m/m-common.js?v=2026031401"></script>
<script src="/static/js/m/m-management.js?v=2026040901"></script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
// Push Notification Service Worker
// 캐싱 없음 — Push 수신 전용
self.addEventListener('push', function(event) {
var data = { title: '알림', body: '새 알림이 있습니다.', url: '/' };
if (event.data) {
try { data = Object.assign(data, event.data.json()); } catch(e) {}
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/static/img/icon-192.png',
badge: '/static/img/badge-72.png',
data: { url: data.url || '/' },
tag: 'tk-notification-' + Date.now(),
renotify: true
})
);
// 메인 페이지에 뱃지 갱신 신호 전송
self.clients.matchAll({ type: 'window' }).then(function(clients) {
clients.forEach(function(client) {
client.postMessage({ type: 'NOTIFICATION_RECEIVED' });
});
});
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var url = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then(function(clients) {
for (var i = 0; i < clients.length; i++) {
if (clients[i].url.includes(self.location.origin)) {
clients[i].navigate(url);
return clients[i].focus();
}
}
return self.clients.openWindow(url);
})
);
});

View File

@@ -0,0 +1,536 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
.report-card {
transition: all 0.2s ease;
border-left: 4px solid transparent;
}
.report-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-left-color: #10b981;
}
.stats-card {
transition: all 0.2s ease;
}
.stats-card:hover {
transform: translateY(-1px);
}
.issue-row {
transition: all 0.2s ease;
}
.issue-row:hover {
background-color: #f9fafb;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-file-excel text-green-500 mr-3"></i>
일일보고서
</h1>
<p class="text-gray-600 mt-1">프로젝트별 진행중/완료 항목을 엑셀로 내보내세요</p>
</div>
</div>
</div>
<!-- 프로젝트 선택 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="space-y-6">
<!-- 프로젝트 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">
<i class="fas fa-folder text-blue-500 mr-2"></i>보고서 생성할 프로젝트 선택
</label>
<select id="reportProjectSelect" class="w-full max-w-md px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg">
<option value="">프로젝트를 선택하세요</option>
</select>
<p class="text-sm text-gray-500 mt-2">
<i class="fas fa-info-circle mr-1"></i>
진행 중인 항목 + 완료되고 한번도 추출 안된 항목이 포함됩니다.
</p>
</div>
<!-- 버튼 -->
<div class="flex items-center space-x-4">
<button id="previewBtn"
onclick="loadPreview()"
class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed hidden">
<i class="fas fa-eye mr-2"></i>미리보기
</button>
<button id="generateReportBtn"
onclick="generateDailyReport()"
class="px-6 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed hidden">
<i class="fas fa-download mr-2"></i>일일보고서 생성
</button>
</div>
</div>
</div>
<!-- 미리보기 섹션 -->
<div id="previewSection" class="hidden">
<!-- 통계 카드 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-chart-bar text-blue-500 mr-2"></i>추출 항목 통계
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="stats-card bg-blue-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-blue-600 mb-1" id="previewTotalCount">0</div>
<div class="text-sm text-blue-700 font-medium">총 추출 수량</div>
</div>
<div class="stats-card bg-orange-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-orange-600 mb-1" id="previewInProgressCount">0</div>
<div class="text-sm text-orange-700 font-medium">진행 중</div>
</div>
<div class="stats-card bg-green-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-green-600 mb-1" id="previewCompletedCount">0</div>
<div class="text-sm text-green-700 font-medium">완료 (미추출)</div>
</div>
<div class="stats-card bg-red-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-red-600 mb-1" id="previewDelayedCount">0</div>
<div class="text-sm text-red-700 font-medium">지연 중</div>
</div>
</div>
</div>
<!-- 항목 목록 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list text-gray-500 mr-2"></i>추출될 항목 목록
</h2>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr class="border-b">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">No</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">부적합명</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">추출이력</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">담당부서</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">신고일</th>
</tr>
</thead>
<tbody id="previewTableBody" class="divide-y divide-gray-200">
<!-- 동적으로 채워짐 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 포함 항목 안내 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list-check text-gray-500 mr-2"></i>보고서 포함 항목 안내
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="report-card bg-blue-50 p-4 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-check-circle text-blue-500 mr-2"></i>
<span class="font-medium text-blue-800">진행 중 항목</span>
</div>
<p class="text-sm text-blue-600">모든 진행 중인 항목이 포함됩니다 (추출 이력과 무관)</p>
</div>
<div class="report-card bg-green-50 p-4 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-check-circle text-green-500 mr-2"></i>
<span class="font-medium text-green-800">완료됨 항목</span>
</div>
<p class="text-sm text-green-600">한번도 추출 안된 완료 항목만 포함, 이후 자동 제외</p>
</div>
<div class="report-card bg-yellow-50 p-4 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-info-circle text-yellow-500 mr-2"></i>
<span class="font-medium text-yellow-800">추출 이력 기록</span>
</div>
<p class="text-sm text-yellow-600">추출 시 자동으로 이력이 기록됩니다</p>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script>
let projects = [];
let selectedProjectId = null;
let previewData = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('일일보고서 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 프로젝트 목록 로드
await loadProjects();
// 공통 헤더 초기화
try {
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports_daily');
}
} catch (headerError) {
console.error('공통 헤더 초기화 오류:', headerError);
}
console.log('일일보고서 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} 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()}`
}
});
if (response.ok) {
projects = await response.json();
populateProjectSelect();
} else {
console.error('프로젝트 로드 실패:', response.status);
}
} catch (error) {
console.error('프로젝트 로드 오류:', error);
}
}
// 프로젝트 선택 옵션 채우기
function populateProjectSelect() {
const select = document.getElementById('reportProjectSelect');
if (!select) {
console.error('reportProjectSelect 요소를 찾을 수 없습니다!');
return;
}
select.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name || project.name;
select.appendChild(option);
});
}
// 프로젝트 선택 시 이벤트
document.addEventListener('change', async function(e) {
if (e.target.id === 'reportProjectSelect') {
selectedProjectId = e.target.value;
const previewBtn = document.getElementById('previewBtn');
const generateBtn = document.getElementById('generateReportBtn');
const previewSection = document.getElementById('previewSection');
if (selectedProjectId) {
previewBtn.classList.remove('hidden');
generateBtn.classList.remove('hidden');
previewSection.classList.add('hidden');
previewData = null;
} else {
previewBtn.classList.add('hidden');
generateBtn.classList.add('hidden');
previewSection.classList.add('hidden');
previewData = null;
}
}
});
// 미리보기 로드
async function loadPreview() {
if (!selectedProjectId) {
alert('프로젝트를 선택해주세요.');
return;
}
try {
const apiUrl = window.API_BASE_URL || '/api';
const token = TokenManager.getToken();
if (!token) {
alert('인증 토큰이 없습니다. 다시 로그인해주세요.');
window.location.href = window.authManager ? window.authManager._getLoginUrl() : '/login.html';
return;
}
const response = await fetch(`${apiUrl}/reports/daily-preview?project_id=${selectedProjectId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
previewData = await response.json();
displayPreview(previewData);
} else {
const errorText = await response.text().catch(() => '');
console.error('미리보기 로드 실패:', response.status, errorText);
if (response.status === 401) {
alert('인증이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = window.authManager ? window.authManager._getLoginUrl() : '/login.html';
} else if (response.status === 403) {
alert('권한이 없습니다. 품질팀 계정으로 로그인해주세요.');
} else {
alert(`미리보기 로드에 실패했습니다. (${response.status})`);
}
}
} catch (error) {
console.error('미리보기 로드 오류:', error);
alert('미리보기 로드 중 오류가 발생했습니다: ' + error.message);
}
}
// 미리보기 표시
function displayPreview(data) {
// 통계 업데이트
const inProgressCount = data.issues.filter(i => i.review_status === 'in_progress').length;
const completedCount = data.issues.filter(i => i.review_status === 'completed').length;
document.getElementById('previewTotalCount').textContent = data.total_issues;
document.getElementById('previewInProgressCount').textContent = inProgressCount;
document.getElementById('previewCompletedCount').textContent = completedCount;
document.getElementById('previewDelayedCount').textContent = data.stats.delayed_count;
// 테이블 업데이트
const tbody = document.getElementById('previewTableBody');
tbody.innerHTML = '';
data.issues.forEach(issue => {
const row = document.createElement('tr');
row.className = 'issue-row';
const statusBadge = getStatusBadge(issue);
const exportBadge = getExportBadge(issue);
const department = getDepartmentText(issue.responsible_department);
const reportDate = issue.report_date ? new Date(issue.report_date).toLocaleDateString('ko-KR') : '-';
row.innerHTML = `
<td class="px-4 py-3 text-sm text-gray-900">${issue.project_sequence_no || issue.id}</td>
<td class="px-4 py-3 text-sm text-gray-900">${issue.final_description || issue.description || '-'}</td>
<td class="px-4 py-3 text-sm">${statusBadge}</td>
<td class="px-4 py-3 text-sm">${exportBadge}</td>
<td class="px-4 py-3 text-sm text-gray-900">${department}</td>
<td class="px-4 py-3 text-sm text-gray-500">${reportDate}</td>
`;
tbody.appendChild(row);
});
// 미리보기 섹션 표시
document.getElementById('previewSection').classList.remove('hidden');
}
// 상태 배지 (지연/진행중/완료 구분)
function getStatusBadge(issue) {
// 완료됨
if (issue.review_status === 'completed') {
return '<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded">완료됨</span>';
}
// 진행 중인 경우 지연 여부 확인
if (issue.review_status === 'in_progress') {
if (issue.expected_completion_date) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const expectedDate = new Date(issue.expected_completion_date);
expectedDate.setHours(0, 0, 0, 0);
if (expectedDate < today) {
return '<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">지연중</span>';
}
}
return '<span class="px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded">진행중</span>';
}
return '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded">' + (issue.review_status || '-') + '</span>';
}
// 추출 이력 배지
function getExportBadge(issue) {
if (issue.last_exported_at) {
const exportDate = new Date(issue.last_exported_at).toLocaleDateString('ko-KR');
return `<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">추출됨 (${issue.export_count || 1}회)</span>`;
} else {
return '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded">미추출</span>';
}
}
// 부서명 변환
function getDepartmentText(department) {
const map = {
'production': '생산',
'quality': '품질',
'purchasing': '구매',
'design': '설계',
'sales': '영업'
};
return map[department] || '-';
}
// 일일보고서 생성
async function generateDailyReport() {
if (!selectedProjectId) {
alert('프로젝트를 선택해주세요.');
return;
}
// 미리보기 데이터가 있고 항목이 0개인 경우
if (previewData && previewData.total_issues === 0) {
alert('추출할 항목이 없습니다.');
return;
}
try {
const button = document.getElementById('generateReportBtn');
const originalText = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>생성 중...';
button.disabled = true;
const apiUrl = window.API_BASE_URL || '/api';
const token = TokenManager.getToken();
if (!token) {
alert('인증 토큰이 없습니다. 다시 로그인해주세요.');
window.location.href = window.authManager ? window.authManager._getLoginUrl() : '/login.html';
return;
}
const response = await fetch(`${apiUrl}/reports/daily-export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
project_id: parseInt(selectedProjectId)
})
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
// 파일명 생성
const project = projects.find(p => p.id == selectedProjectId);
const today = new Date().toISOString().split('T')[0];
a.download = `${project.project_name}_일일보고서_${today}.xlsx`;
document.body.appendChild(a);
a.click();
// Edge 호환: revokeObjectURL을 지연시켜 다운로드가 시작될 시간 확보
setTimeout(() => {
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}, 500);
// 성공 메시지
showSuccessMessage('일일보고서가 성공적으로 생성되었습니다!');
// 미리보기 새로고침
if (previewData) {
setTimeout(() => loadPreview(), 1000);
}
} else {
const errorText = await response.text().catch(() => '');
console.error('보고서 생성 실패:', response.status, errorText);
if (response.status === 401) {
alert('인증이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = window.authManager ? window.authManager._getLoginUrl() : '/login.html';
} else if (response.status === 403) {
alert('권한이 없습니다. 품질팀 계정으로 로그인해주세요.');
} else {
alert(`보고서 생성에 실패했습니다. (${response.status})`);
}
}
} catch (error) {
console.error('보고서 생성 오류:', error);
alert('보고서 생성 중 오류가 발생했습니다: ' + error.message);
} finally {
const button = document.getElementById('generateReportBtn');
button.innerHTML = '<i class="fas fa-download mr-2"></i>일일보고서 생성';
button.disabled = false;
}
}
// 성공 메시지 표시
function showSuccessMessage(message) {
const successDiv = document.createElement('div');
successDiv.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
successDiv.innerHTML = `
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(successDiv);
setTimeout(() => {
successDiv.remove();
}, 3000);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>월간보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-calendar-alt text-purple-500 mr-3"></i>
월간보고서
</h1>
<p class="text-gray-600 mt-1">월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합적으로 분석하세요</p>
</div>
</div>
</div>
<!-- 준비중 안내 -->
<div class="bg-white rounded-xl shadow-sm p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-24 h-24 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-6">
<i class="fas fa-calendar-alt text-purple-500 text-3xl"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">월간보고서 준비중</h2>
<p class="text-gray-600 mb-6">
월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합한 보고서 기능을 준비하고 있습니다.
</p>
<div class="bg-purple-50 p-4 rounded-lg">
<h3 class="font-semibold text-purple-800 mb-2">예정 기능</h3>
<ul class="text-sm text-purple-700 space-y-1">
<li>• 월간 부적합 발생 현황</li>
<li>• 월간 처리 완료 현황</li>
<li>• 부서별 성과 분석</li>
<li>• 월간 트렌드 및 개선사항</li>
<li>• 경영진 보고용 요약</li>
</ul>
</div>
<div class="mt-6">
<button onclick="window.history.back()"
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
</button>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('월간보고서 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 공통 헤더 초기화
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports_monthly');
}
console.log('월간보고서 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>주간보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-calendar-week text-blue-500 mr-3"></i>
주간보고서
</h1>
<p class="text-gray-600 mt-1">주간 단위로 집계된 부적합 현황 및 처리 결과를 확인하세요</p>
</div>
</div>
</div>
<!-- 준비중 안내 -->
<div class="bg-white rounded-xl shadow-sm p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
<i class="fas fa-calendar-week text-blue-500 text-3xl"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">주간보고서 준비중</h2>
<p class="text-gray-600 mb-6">
주간 단위로 집계된 부적합 현황 및 처리 결과를 정리한 보고서 기능을 준비하고 있습니다.
</p>
<div class="bg-blue-50 p-4 rounded-lg">
<h3 class="font-semibold text-blue-800 mb-2">예정 기능</h3>
<ul class="text-sm text-blue-700 space-y-1">
<li>• 주간 부적합 발생 현황</li>
<li>• 주간 처리 완료 현황</li>
<li>• 부서별 처리 성과</li>
<li>• 주간 트렌드 분석</li>
</ul>
</div>
<div class="mt-6">
<button onclick="window.history.back()"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
</button>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('주간보고서 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 공통 헤더 초기화
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports_weekly');
}
console.log('주간보고서 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
.report-card {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.report-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.15);
border-left-color: #3b82f6;
}
.report-card.daily-report {
border-left-color: #10b981;
}
.report-card.daily-report:hover {
border-left-color: #059669;
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stats-card {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
</style>
</head>
<body class="bg-gray-50">
<!-- 공통 헤더 -->
<div id="commonHeader"></div>
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-chart-bar text-red-500 mr-3"></i>
보고서
</h1>
<p class="text-gray-600 mt-1">다양한 보고서를 생성하고 관리할 수 있습니다</p>
</div>
</div>
</div>
<!-- 보고서 카테고리 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list text-gray-500 mr-2"></i>보고서 유형 선택
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- 일일보고서 -->
<a href="/reports-daily.html" class="report-card bg-green-50 p-4 rounded-lg hover:bg-green-100 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-excel text-green-600"></i>
</div>
<span class="bg-green-100 text-green-800 text-xs font-medium px-2 py-1 rounded-full">
사용 가능
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">일일보고서</h3>
<p class="text-sm text-gray-600 mb-3">
관리함 데이터를 기반으로 품질팀용 일일보고서를 엑셀 형태로 생성합니다.
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-green-600 font-medium">
<i class="fas fa-check-circle mr-1"></i>진행중 항목 포함
</span>
<i class="fas fa-arrow-right text-gray-400"></i>
</div>
</a>
<!-- 주간보고서 -->
<a href="/reports-weekly.html" class="report-card bg-blue-50 p-4 rounded-lg hover:bg-blue-100 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-calendar-week text-blue-600"></i>
</div>
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
준비중
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">주간보고서</h3>
<p class="text-sm text-gray-600 mb-3">
주간 단위로 집계된 부적합 현황 및 처리 결과를 정리한 보고서입니다.
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-blue-600 font-medium">
<i class="fas fa-calendar mr-1"></i>주간 집계
</span>
<i class="fas fa-arrow-right text-gray-400"></i>
</div>
</a>
<!-- 월간보고서 -->
<a href="/reports-monthly.html" class="report-card bg-purple-50 p-4 rounded-lg hover:bg-purple-100 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-calendar-alt text-purple-600"></i>
</div>
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
준비중
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">월간보고서</h3>
<p class="text-sm text-gray-600 mb-3">
월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합한 보고서입니다.
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-purple-600 font-medium">
<i class="fas fa-chart-line mr-1"></i>월간 분석
</span>
<i class="fas fa-arrow-right text-gray-400"></i>
</div>
</a>
</div>
</div>
<!-- 보고서 안내 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-info-circle text-blue-500 mr-2"></i>보고서 이용 안내
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<h3 class="font-semibold text-gray-800">📊 일일보고서</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li>• 관리함의 진행 중 항목 무조건 포함</li>
<li>• 완료됨 항목은 첫 내보내기에만 포함</li>
<li>• 프로젝트별 개별 생성</li>
<li>• 엑셀 형태로 다운로드</li>
</ul>
</div>
<div class="space-y-3">
<h3 class="font-semibold text-gray-800">🚀 향후 계획</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li>• 주간보고서: 주간 집계 및 트렌드 분석</li>
<li>• 월간보고서: 월간 성과 및 개선사항</li>
<li>• 자동 이메일 발송 기능</li>
<li>• 대시보드 형태의 실시간 리포트</li>
</ul>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/permissions.js?v=2026031401"></script>
<script src="/static/js/components/common-header.js?v=2026031401"></script>
<script src="/static/js/api.js?v=2026031401"></script>
<script src="/static/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/core/auth-manager.js?v=2026031401"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('보고서 메인 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 공통 헤더 초기화
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports');
}
console.log('보고서 메인 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
</script>
</body>
</html>

View 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;
}
}

View File

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

View File

@@ -0,0 +1,16 @@
/* issues-archive.css — 폐기함 페이지 전용 스타일 */
.archived-card {
border-left: 4px solid #6b7280;
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
}
.completed-card {
border-left: 4px solid #10b981;
background: linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%);
}
.chart-container {
position: relative;
height: 300px;
}

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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}`)
};

View 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();
});

View File

@@ -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;

View File

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

View File

@@ -0,0 +1,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;

View 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();

View File

@@ -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();

View 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;

View File

@@ -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();

View 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);

View 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;

View 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;

File diff suppressed because one or more lines are too long

View 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>';
}

View 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);

View 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);

View 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);

View File

@@ -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();

View 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, "&apos;")})' class="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
<i class="fas fa-edit mr-1"></i>수정
</button>
` : ''}
${canDelete ? `
<button onclick="confirmDelete(${issue.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div>
` : ''}
</div>
</div>
</div>
`;
return div;
}
// 관리 버튼 클릭 처리
function handleAdminClick() {
if (currentUser.role === 'admin') {
// 관리자: 사용자 관리 페이지로 이동
window.location.href = 'admin.html';
}
}
// 비밀번호 변경 모달 표시
function showPasswordChangeModal() {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
modal.innerHTML = `
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">비밀번호 변경</h3>
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form id="passwordChangeForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
<input type="password" id="currentPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
<input type="password" id="newPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required minlength="6">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
<input type="password" id="confirmPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
</div>
<div class="flex gap-2 pt-4">
<button type="button" onclick="this.closest('.fixed').remove()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
변경
</button>
</div>
</form>
</div>
`;
document.body.appendChild(modal);
// 폼 제출 이벤트 처리
document.getElementById('passwordChangeForm').addEventListener('submit', handlePasswordChange);
}
// 비밀번호 변경 처리
async function handlePasswordChange(e) {
e.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
// 새 비밀번호 확인
if (newPassword !== confirmPassword) {
alert('새 비밀번호가 일치하지 않습니다.');
return;
}
// 현재 비밀번호 확인 (localStorage 기반)
let users = JSON.parse(localStorage.getItem('work-report-users') || '[]');
// 임시 비밀번호 생성 (클라이언트에 하드코딩하지 않음)
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);

View File

@@ -0,0 +1,332 @@
/**
* issues-archive.js — 폐기함 페이지 스크립트
*/
let currentUser = null;
let issues = [];
let projects = [];
let filteredIssues = [];
// API 로드 후 초기화 함수
async function initializeArchive() {
const token = TokenManager.getToken();
if (!token) {
window.location.href = '/index.html';
return;
}
try {
const user = await AuthAPI.getCurrentUser();
currentUser = user;
localStorage.setItem('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

View File

@@ -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

View 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);
})();

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
/**
* 서비스 워커 정리용
* 기존 캐시를 모두 삭제하고 자신을 비활성화합니다.
*/
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => caches.delete(cacheName))
);
})
.then(() => self.clients.claim())
.then(() => self.registration.unregister())
);
});