feat: 프로젝트 관리 및 비밀번호 변경 기능 개선

주요 변경사항:
- 비활성화된 프로젝트 관리 기능 추가
  * 프로젝트 관리 페이지에 접을 수 있는 비활성 프로젝트 섹션 추가
  * 비활성화된 프로젝트 복구 기능 제공
  * 업로드 시에는 활성 프로젝트만 표시되도록 API 호출 분리

- 헤더 비밀번호 변경 기능 완전 구현
  * CommonHeader.js에 완전한 비밀번호 변경 모달 구현
  * ESC 키 지원, 실시간 유효성 검사, 토스트 메시지 추가
  * 중복 코드 제거 및 통일된 함수 호출 구조

- 수신함 수정 내용 표시 문제 해결
  * description 우선 표시로 최신 수정 내용 반영
  * 관리함에서 final_description/final_category 업데이트 로직 추가

- 현황판 날짜 그룹화 개선
  * 업로드일 기준에서 관리함 진입일(reviewed_at) 기준으로 변경
  * Invalid Date 오류 해결

- 프로젝트 관리 페이지 JavaScript 오류 수정
  * 중복 변수 선언 및 함수 참조 오류 해결
  * 페이지 초기화 로직 개선

기술적 개선:
- API 호출 최적화 (active_only 매개변수 명시적 전달)
- 프론트엔드 표시 우선순위 통일 (description || final_description)
- 백엔드 final_* 필드 업데이트 로직 추가
This commit is contained in:
hyungi
2025-10-26 15:28:23 +09:00
parent fd0579805c
commit c16fc53f3b
25 changed files with 295 additions and 169 deletions

View File

@@ -76,6 +76,18 @@ async def update_issue(
import traceback import traceback
traceback.print_exc() traceback.print_exc()
continue continue
elif field == 'final_description' and value:
# final_description 업데이트 시 description도 함께 업데이트
issue.final_description = value
issue.description = value
print(f"✅ final_description 및 description 업데이트: {value[:50]}...")
continue
elif field == 'final_category' and value:
# final_category 업데이트 시 category도 함께 업데이트
issue.final_category = value
issue.category = value
print(f"✅ final_category 및 category 업데이트: {value}")
continue
elif field == 'expected_completion_date' and value: elif field == 'expected_completion_date' and value:
# 날짜 필드 처리 # 날짜 필드 처리
if not value.endswith('T00:00:00'): if not value.endswith('T00:00:00'):

View File

@@ -183,7 +183,7 @@
<!-- 하단 메뉴 --> <!-- 하단 메뉴 -->
<div class="absolute bottom-0 left-0 right-0 p-4 border-t bg-gray-50"> <div class="absolute bottom-0 left-0 right-0 p-4 border-t bg-gray-50">
<button onclick="showPasswordChangeModal()" class="w-full text-left p-2 rounded-lg hover:bg-gray-100 transition-colors"> <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> <i class="fas fa-key mr-3 text-gray-500"></i>
<span class="text-gray-700">비밀번호 변경</span> <span class="text-gray-700">비밀번호 변경</span>
</button> </button>
@@ -296,44 +296,6 @@
</div> </div>
</main> </main>
<!-- 비밀번호 변경 모달 -->
<div id="passwordModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-xl 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="hidePasswordChangeModal()" 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="input-field w-full px-3 py-2 rounded-lg" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
<input type="password" id="newPassword" class="input-field w-full px-3 py-2 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="input-field w-full px-3 py-2 rounded-lg" required>
</div>
<div class="flex gap-2 pt-4">
<button type="button" onclick="hidePasswordChangeModal()"
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 btn-primary rounded-lg">
변경
</button>
</div>
</form>
</div>
</div>
<!-- Scripts --> <!-- Scripts -->
<script src="/static/js/utils/date-utils.js"></script> <script src="/static/js/utils/date-utils.js"></script>

View File

@@ -1192,8 +1192,8 @@
console.log('=== 프로젝트 로드 시작 (API) ==='); console.log('=== 프로젝트 로드 시작 (API) ===');
try { try {
// API에서 프로젝트 로드 (인증 없이) // API에서 활성 프로젝트 로드 (업로드용)
const apiProjects = await ProjectsAPI.getAll(false); const apiProjects = await ProjectsAPI.getAll(true);
// API 데이터를 UI 형식으로 변환 // API 데이터를 UI 형식으로 변환
const projects = apiProjects.map(p => ({ const projects = apiProjects.map(p => ({

View File

@@ -322,9 +322,9 @@
if (response.ok) { if (response.ok) {
const allIssues = await response.json(); const allIssues = await response.json();
// 완료, 보관, 취소된 부적합만 필터링 // 폐기된 부적합만 필터링 (폐기함 전용)
issues = allIssues.filter(issue => issues = allIssues.filter(issue =>
['completed', 'archived', 'cancelled'].includes(issue.status) issue.review_status === 'disposed'
); );
filterIssues(); filterIssues();
@@ -422,8 +422,11 @@
container.innerHTML = filteredIssues.map(issue => { container.innerHTML = filteredIssues.map(issue => {
const project = projects.find(p => p.id === issue.project_id); const project = projects.find(p => p.id === issue.project_id);
const completedDate = new Date(issue.updated_at || issue.created_at).toLocaleDateString('ko-KR');
const cardClass = issue.status === 'completed' ? 'completed-card' : 'archived-card'; // 폐기함은 폐기된 것만 표시
const completedDate = issue.disposed_at ? new Date(issue.disposed_at).toLocaleDateString('ko-KR') : 'Invalid Date';
const statusText = '폐기';
const cardClass = 'archived-card';
return ` return `
<div class="issue-card p-6 ${cardClass} cursor-pointer" <div class="issue-card p-6 ${cardClass} cursor-pointer"
@@ -441,7 +444,7 @@
<div class="flex items-center text-sm text-gray-500 space-x-4"> <div class="flex items-center text-sm text-gray-500 space-x-4">
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span> <span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span>
${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''} ${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''}
<span><i class="fas fa-clock mr-1"></i>완료: ${completedDate}</span> <span><i class="fas fa-clock mr-1"></i>${statusText}: ${completedDate}</span>
</div> </div>
</div> </div>

View File

@@ -423,22 +423,29 @@
emptyState.classList.add('hidden'); emptyState.classList.add('hidden');
// 날짜별로 그룹화 // 날짜별로 그룹화 (관리함 진입일 기준)
const groupedByDate = {}; const groupedByDate = {};
const dateObjects = {}; // 정렬용 Date 객체 저장
filteredIssues.forEach(issue => { filteredIssues.forEach(issue => {
const dateKey = new Date(issue.report_date).toLocaleDateString('ko-KR'); // reviewed_at이 있으면 관리함 진입일, 없으면 report_date 사용
const dateToUse = issue.reviewed_at || issue.report_date;
const dateObj = new Date(dateToUse);
const dateKey = dateObj.toLocaleDateString('ko-KR');
if (!groupedByDate[dateKey]) { if (!groupedByDate[dateKey]) {
groupedByDate[dateKey] = []; groupedByDate[dateKey] = [];
dateObjects[dateKey] = dateObj;
} }
groupedByDate[dateKey].push(issue); groupedByDate[dateKey].push(issue);
}); });
// 날짜별 그룹 생성 // 날짜별 그룹 생성
const dateGroups = Object.keys(groupedByDate) const dateGroups = Object.keys(groupedByDate)
.sort((a, b) => new Date(b) - new Date(a)) // 최신순 .sort((a, b) => dateObjects[b] - dateObjects[a]) // 최신순
.map(dateKey => { .map(dateKey => {
const issues = groupedByDate[dateKey]; const issues = groupedByDate[dateKey];
const formattedDate = new Date(dateKey).toLocaleDateString('ko-KR', { const formattedDate = dateObjects[dateKey].toLocaleDateString('ko-KR', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit' day: '2-digit'
@@ -451,7 +458,7 @@
<i class="fas fa-chevron-down transition-transform duration-200" id="chevron-${dateKey}"></i> <i class="fas fa-chevron-down transition-transform duration-200" id="chevron-${dateKey}"></i>
<span class="font-semibold text-gray-800">${formattedDate}</span> <span class="font-semibold text-gray-800">${formattedDate}</span>
<span class="text-sm text-gray-500">(${issues.length}건)</span> <span class="text-sm text-gray-500">(${issues.length}건)</span>
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">업로드일</span> <span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">관리함 진입일</span>
</div> </div>
</div> </div>
<div class="collapse-content mt-4" id="content-${dateKey}"> <div class="collapse-content mt-4" id="content-${dateKey}">
@@ -468,14 +475,14 @@
// 부적합명 추출 (첫 번째 줄) // 부적합명 추출 (첫 번째 줄)
function getIssueTitle(issue) { function getIssueTitle(issue) {
const description = issue.final_description || issue.description || ''; const description = issue.description || issue.final_description || '';
const lines = description.split('\n'); const lines = description.split('\n');
return lines[0] || '부적합명 없음'; return lines[0] || '부적합명 없음';
} }
// 상세 내용 추출 (두 번째 줄부터) // 상세 내용 추출 (두 번째 줄부터)
function getIssueDetail(issue) { function getIssueDetail(issue) {
const description = issue.final_description || issue.description || ''; const description = issue.description || issue.final_description || '';
const lines = description.split('\n'); const lines = description.split('\n');
return lines.slice(1).join('\n') || '상세 내용 없음'; return lines.slice(1).join('\n') || '상세 내용 없음';
} }
@@ -605,7 +612,7 @@
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="inline-flex items-center bg-gradient-to-r from-yellow-400 to-orange-400 text-white px-2 py-1 rounded-full text-xs font-medium shadow-sm"> <span class="inline-flex items-center bg-gradient-to-r from-yellow-400 to-orange-400 text-white px-2 py-1 rounded-full text-xs font-medium shadow-sm">
<i class="fas fa-tag mr-1"></i> <i class="fas fa-tag mr-1"></i>
${getCategoryText(issue.final_category || issue.category)} ${getCategoryText(issue.category || issue.final_category)}
</span> </span>
</div> </div>
</div> </div>
@@ -838,7 +845,7 @@
if (selectedProject) { if (selectedProject) {
filterByProject(); filterByProject();
} else { } else {
loadDashboardData(); initializeDashboard();
} }
} }

View File

@@ -812,7 +812,7 @@
</div> </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.description}</h3> <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="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4 text-sm">
@@ -822,7 +822,7 @@
</div> </div>
<div class="flex items-center text-gray-600"> <div class="flex items-center text-gray-600">
<i class="fas fa-tag mr-2 text-green-500"></i> <i class="fas fa-tag mr-2 text-green-500"></i>
<span>${getCategoryText(issue.category)}</span> <span>${getCategoryText(issue.category || issue.final_category)}</span>
</div> </div>
<div class="flex items-center text-gray-600"> <div class="flex items-center text-gray-600">
<i class="fas fa-camera mr-2 text-purple-500"></i> <i class="fas fa-camera mr-2 text-purple-500"></i>
@@ -1075,10 +1075,10 @@
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="text-sm font-medium text-gray-900 mb-1"> <div class="text-sm font-medium text-gray-900 mb-1">
${issue.description} ${issue.description || issue.final_description}
</div> </div>
<div class="flex items-center gap-2 text-xs text-gray-500"> <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)}</span> <span class="px-2 py-1 bg-gray-100 rounded">${getCategoryText(issue.category || issue.final_category)}</span>
<span>신고자: ${issue.reporter_name}</span> <span>신고자: ${issue.reporter_name}</span>
${issue.duplicate_count > 0 ? `<span class="text-orange-600">중복 ${issue.duplicate_count}건</span>` : ''} ${issue.duplicate_count > 0 ? `<span class="text-orange-600">중복 ${issue.duplicate_count}건</span>` : ''}
</div> </div>
@@ -1176,8 +1176,8 @@
originalInfo.innerHTML = ` originalInfo.innerHTML = `
<div class="space-y-2"> <div class="space-y-2">
<div><strong>프로젝트:</strong> ${project ? project.project_name : '미지정'}</div> <div><strong>프로젝트:</strong> ${project ? project.project_name : '미지정'}</div>
<div><strong>카테고리:</strong> ${getCategoryText(issue.category)}</div> <div><strong>카테고리:</strong> ${getCategoryText(issue.category || issue.final_category)}</div>
<div><strong>설명:</strong> ${issue.description}</div> <div><strong>설명:</strong> ${issue.description || issue.final_description}</div>
<div><strong>등록자:</strong> ${issue.reporter?.username || '알 수 없음'}</div> <div><strong>등록자:</strong> ${issue.reporter?.username || '알 수 없음'}</div>
<div><strong>등록일:</strong> ${new Date(issue.report_date).toLocaleDateString('ko-KR')}</div> <div><strong>등록일:</strong> ${new Date(issue.report_date).toLocaleDateString('ko-KR')}</div>
</div> </div>
@@ -1196,12 +1196,13 @@
reviewProjectSelect.appendChild(option); reviewProjectSelect.appendChild(option);
}); });
// 현재 값들로 폼 초기화 // 현재 값들로 폼 초기화 (최신 내용 우선 사용)
document.getElementById('reviewCategory').value = issue.category; document.getElementById('reviewCategory').value = issue.category || issue.final_category;
// 기존 description을 title과 description으로 분리 (첫 번째 줄을 title로 사용) // 최신 description을 title과 description으로 분리 (첫 번째 줄을 title로 사용)
const lines = issue.description.split('\n'); const currentDescription = issue.description || issue.final_description;
const lines = currentDescription.split('\n');
document.getElementById('reviewTitle').value = lines[0] || ''; document.getElementById('reviewTitle').value = lines[0] || '';
document.getElementById('reviewDescription').value = lines.slice(1).join('\n') || issue.description; document.getElementById('reviewDescription').value = lines.slice(1).join('\n') || currentDescription;
document.getElementById('reviewModal').classList.remove('hidden'); document.getElementById('reviewModal').classList.remove('hidden');
} }

View File

@@ -693,14 +693,14 @@
// 부적합명 추출 (첫 번째 줄) // 부적합명 추출 (첫 번째 줄)
function getIssueTitle(issue) { function getIssueTitle(issue) {
const description = issue.final_description || issue.description || ''; const description = issue.description || issue.final_description || '';
const lines = description.split('\n'); const lines = description.split('\n');
return lines[0] || '부적합명 없음'; return lines[0] || '부적합명 없음';
} }
// 상세 내용 추출 (두 번째 줄부터) // 상세 내용 추출 (두 번째 줄부터)
function getIssueDetail(issue) { function getIssueDetail(issue) {
const description = issue.final_description || issue.description || ''; const description = issue.description || issue.final_description || '';
const lines = description.split('\n'); const lines = description.split('\n');
return lines.slice(1).join('\n') || '상세 내용 없음'; return lines.slice(1).join('\n') || '상세 내용 없음';
} }
@@ -833,7 +833,7 @@
<label class="block text-sm font-medium text-gray-700 mb-2">원인 분류</label> <label class="block text-sm font-medium text-gray-700 mb-2">원인 분류</label>
<div class="p-3 bg-gray-50 rounded-lg text-gray-800"> <div class="p-3 bg-gray-50 rounded-lg text-gray-800">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
${getCategoryText(issue.final_category || issue.category)} ${getCategoryText(issue.category || issue.final_category)}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -142,6 +142,24 @@
<!-- 프로젝트 목록이 여기에 표시됩니다 --> <!-- 프로젝트 목록이 여기에 표시됩니다 -->
</div> </div>
</div> </div>
<!-- 비활성화된 프로젝트 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6 mt-6">
<div class="flex justify-between items-center mb-4 cursor-pointer" onclick="toggleInactiveProjects()">
<div class="flex items-center space-x-2">
<i id="inactiveToggleIcon" class="fas fa-chevron-down transition-transform duration-200"></i>
<h2 class="text-lg font-semibold text-gray-600">비활성화된 프로젝트</h2>
<span id="inactiveProjectCount" class="bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-sm font-medium">0</span>
</div>
<div class="text-sm text-gray-500">
<i class="fas fa-info-circle mr-1"></i>클릭하여 펼치기/접기
</div>
</div>
<div id="inactiveProjectsList" class="space-y-3">
<!-- 비활성화된 프로젝트 목록이 여기에 표시됩니다 -->
</div>
</div>
</main> </main>
<!-- API 스크립트 먼저 로드 (최강 캐시 무력화) --> <!-- API 스크립트 먼저 로드 (최강 캐시 무력화) -->
@@ -170,40 +188,50 @@
console.log('🚀 캐시 버스터:', cacheBuster); console.log('🚀 캐시 버스터:', cacheBuster);
</script> </script>
<!-- API 스크립트 동적 로딩 --> <!-- 메인 스크립트 -->
<script> <script>
// 최강 캐시 무력화로 API 스크립트 로드 // 관리자 권한 확인 함수
const timestamp = new Date().getTime(); async function checkAdminAccess() {
const random1 = Math.random() * 1000000; try {
const random2 = Math.floor(Math.random() * 1000000); const currentUser = await AuthAPI.getCurrentUser();
const cacheBuster = `${timestamp}-${random1}-${random2}`; if (!currentUser || currentUser.role !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/index.html';
return;
}
// 권한 확인 후 페이지 초기화
initializeProjectManagement();
} catch (error) {
console.error('권한 확인 실패:', error);
alert('로그인이 필요합니다.');
window.location.href = '/index.html';
}
}
// 기존 api.js 스크립트 제거 // 프로젝트 관리 페이지 초기화 함수
const existingScripts = document.querySelectorAll('script[src*="api.js"]'); async function initializeProjectManagement() {
existingScripts.forEach(script => script.remove()); try {
console.log('🚀 프로젝트 관리 페이지 초기화 시작');
const script = document.createElement('script'); // 헤더 애니메이션 시작
script.src = `/static/js/api.js?v=${timestamp}&cb=${random1}&bust=${random2}&force=${Date.now()}`; animateHeaderAppearance();
script.onload = function() {
console.log('✅ API 스크립트 로드 완료 (project-management.html)');
console.log('🔍 API_BASE_URL:', typeof API_BASE_URL !== 'undefined' ? API_BASE_URL : 'undefined');
// API 로드 후 인증 확인 시작
checkAdminAccess();
};
script.setAttribute('cache-control', 'no-cache, no-store, must-revalidate');
script.setAttribute('pragma', 'no-cache');
script.setAttribute('expires', '0');
script.crossOrigin = 'anonymous';
document.head.appendChild(script);
console.log('📱 프로젝트 관리 - 캐시 버스터:', cacheBuster); // 프로젝트 목록 로드
await loadProjects();
console.log('✅ 프로젝트 관리 페이지 초기화 완료');
} catch (error) {
console.error('❌ 프로젝트 관리 페이지 초기화 실패:', error);
alert('페이지 로드 중 오류가 발생했습니다.');
}
}
</script> </script>
<script src="/static/js/core/permissions.js?v=20251025"></script> <script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script> <script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script> <script src="/static/js/core/page-manager.js?v=20251025"></script>
<script> <script>
// 사용자 확인 (관리자만 접근 가능) // 전역 변수
let currentUser = null; let currentUser = null;
// 애니메이션 함수들 // 애니메이션 함수들
@@ -309,7 +337,7 @@
console.log('프로젝트 로드 시작 (API)'); console.log('프로젝트 로드 시작 (API)');
try { try {
// API에서 프로젝트 로드 // API에서 모든 프로젝트 로드 (활성/비활성 모두)
const apiProjects = await ProjectsAPI.getAll(false); const apiProjects = await ProjectsAPI.getAll(false);
// API 데이터를 그대로 사용 (필드명 통일) // API 데이터를 그대로 사용 (필드명 통일)
@@ -368,11 +396,16 @@
// 프로젝트 목록 표시 // 프로젝트 목록 표시
function displayProjectList() { function displayProjectList() {
const container = document.getElementById('projectsList'); const activeContainer = document.getElementById('projectsList');
container.innerHTML = ''; const inactiveContainer = document.getElementById('inactiveProjectsList');
const inactiveCount = document.getElementById('inactiveProjectCount');
activeContainer.innerHTML = '';
inactiveContainer.innerHTML = '';
if (projects.length === 0) { if (projects.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center py-8">등록된 프로젝트가 없습니다.</p>'; activeContainer.innerHTML = '<p class="text-gray-500 text-center py-8">등록된 프로젝트가 없습니다.</p>';
inactiveCount.textContent = '0';
return; return;
} }
@@ -383,15 +416,12 @@
console.log('전체 프로젝트:', projects.length, '개'); console.log('전체 프로젝트:', projects.length, '개');
console.log('활성 프로젝트:', activeProjects.length, '개'); console.log('활성 프로젝트:', activeProjects.length, '개');
console.log('비활성 프로젝트:', inactiveProjects.length, '개'); console.log('비활성 프로젝트:', inactiveProjects.length, '개');
console.log('비활성 프로젝트 목록:', inactiveProjects);
// 비활성 프로젝트 개수 업데이트
inactiveCount.textContent = inactiveProjects.length;
// 활성 프로젝트 표시 // 활성 프로젝트 표시
if (activeProjects.length > 0) { if (activeProjects.length > 0) {
const activeHeader = document.createElement('div');
activeHeader.className = 'mb-4';
activeHeader.innerHTML = '<h3 class="text-md font-semibold text-green-700"><i class="fas fa-check-circle mr-2"></i>활성 프로젝트 (' + activeProjects.length + '개)</h3>';
container.appendChild(activeHeader);
activeProjects.forEach(project => { activeProjects.forEach(project => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'border border-green-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-green-50'; div.className = 'border border-green-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-green-50';
@@ -421,20 +451,17 @@
</div> </div>
</div> </div>
`; `;
container.appendChild(div); activeContainer.appendChild(div);
}); });
} else {
activeContainer.innerHTML = '<p class="text-gray-500 text-center py-8">활성 프로젝트가 없습니다.</p>';
} }
// 비활성 프로젝트 표시 // 비활성 프로젝트 표시
if (inactiveProjects.length > 0) { if (inactiveProjects.length > 0) {
const inactiveHeader = document.createElement('div');
inactiveHeader.className = 'mb-4 mt-6';
inactiveHeader.innerHTML = '<h3 class="text-md font-semibold text-gray-500"><i class="fas fa-pause-circle mr-2"></i>비활성 프로젝트 (' + inactiveProjects.length + '개)</h3>';
container.appendChild(inactiveHeader);
inactiveProjects.forEach(project => { inactiveProjects.forEach(project => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-gray-50 opacity-75'; div.className = 'border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-gray-50';
div.innerHTML = ` div.innerHTML = `
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div class="flex-1"> <div class="flex-1">
@@ -458,8 +485,28 @@
</div> </div>
</div> </div>
`; `;
container.appendChild(div); inactiveContainer.appendChild(div);
}); });
} else {
inactiveContainer.innerHTML = '<p class="text-gray-500 text-center py-8">비활성화된 프로젝트가 없습니다.</p>';
}
}
// 비활성 프로젝트 섹션 토글
function toggleInactiveProjects() {
const inactiveList = document.getElementById('inactiveProjectsList');
const toggleIcon = document.getElementById('inactiveToggleIcon');
if (inactiveList.style.display === 'none') {
// 펼치기
inactiveList.style.display = 'block';
toggleIcon.classList.remove('fa-chevron-right');
toggleIcon.classList.add('fa-chevron-down');
} else {
// 접기
inactiveList.style.display = 'none';
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-right');
} }
} }

View File

@@ -321,7 +321,7 @@ function checkPageAccess(pageName) {
// 프로젝트 API // 프로젝트 API
const ProjectsAPI = { const ProjectsAPI = {
getAll: (activeOnly = false) => { getAll: (activeOnly = false) => {
const params = activeOnly ? '?active_only=true' : ''; const params = `?active_only=${activeOnly}`;
return apiRequest(`/projects/${params}`); return apiRequest(`/projects/${params}`);
}, },

View File

@@ -293,11 +293,7 @@ class App {
* 이벤트 리스너 등록 * 이벤트 리스너 등록
*/ */
registerEventListeners() { registerEventListeners() {
// 비밀번호 변경 // 비밀번호 변경은 CommonHeader에서 처리
document.getElementById('passwordChangeForm').addEventListener('submit', (e) => {
e.preventDefault();
this.handlePasswordChange();
});
// 모바일 반응형 // 모바일 반응형
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@@ -358,46 +354,7 @@ class App {
document.getElementById('mobileOverlay').classList.remove('active'); document.getElementById('mobileOverlay').classList.remove('active');
} }
/** // 비밀번호 변경 기능은 CommonHeader.js에서 처리됩니다.
* 비밀번호 변경 모달 표시
*/
showPasswordChangeModal() {
document.getElementById('passwordModal').classList.remove('hidden');
document.getElementById('passwordModal').classList.add('flex');
}
/**
* 비밀번호 변경 모달 숨기기
*/
hidePasswordChangeModal() {
document.getElementById('passwordModal').classList.add('hidden');
document.getElementById('passwordModal').classList.remove('flex');
document.getElementById('passwordChangeForm').reset();
}
/**
* 비밀번호 변경 처리
*/
async handlePasswordChange() {
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) {
this.showError('새 비밀번호가 일치하지 않습니다.');
return;
}
try {
// API 호출 (구현 필요)
// await AuthAPI.changePassword(currentPassword, newPassword);
this.showSuccess('비밀번호가 성공적으로 변경되었습니다.');
this.hidePasswordChangeModal();
} catch (error) {
this.showError('비밀번호 변경에 실패했습니다.');
}
}
/** /**
* 로그아웃 * 로그아웃
@@ -472,13 +429,7 @@ function toggleSidebar() {
window.app.toggleSidebar(); window.app.toggleSidebar();
} }
function showPasswordChangeModal() { // 비밀번호 변경 기능은 CommonHeader.showPasswordModal()을 사용합니다.
window.app.showPasswordChangeModal();
}
function hidePasswordChangeModal() {
window.app.hidePasswordChangeModal();
}
function logout() { function logout() {
window.app.logout(); window.app.logout();

View File

@@ -479,8 +479,151 @@ class CommonHeader {
* 비밀번호 변경 모달 표시 * 비밀번호 변경 모달 표시
*/ */
static showPasswordModal() { static showPasswordModal() {
// 비밀번호 변경 모달 구현 (기존 코드 재사용) // 기존 모달이 있으면 제거
alert('비밀번호 변경 기능은 관리자 페이지에서 이용해주세요.'); 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';
toast.innerHTML = `<i class="fas ${icon} mr-2"></i>${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);
} }
/** /**