diff --git a/frontend/static/css/mobile-calendar.css b/frontend/static/css/mobile-calendar.css
new file mode 100644
index 0000000..97360de
--- /dev/null
+++ b/frontend/static/css/mobile-calendar.css
@@ -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;
+ }
+}
diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js
index ea622ed..bd15cb4 100644
--- a/frontend/static/js/api.js
+++ b/frontend/static/js/api.js
@@ -141,7 +141,10 @@ const AuthAPI = {
body: JSON.stringify(userData)
}),
- getUsers: () => apiRequest('/auth/users'),
+ getUsers: () => {
+ console.log('🔍 AuthAPI.getUsers 호출 - 엔드포인트: /auth/users');
+ return apiRequest('/auth/users');
+ },
updateUser: (userId, userData) => apiRequest(`/auth/users/${userId}`, {
method: 'PUT',
@@ -160,10 +163,10 @@ const AuthAPI = {
})
}),
- resetPassword: (userId, newPassword = '000000') => apiRequest(`/auth/users/${userId}`, {
- method: 'PUT',
+ resetPassword: (userId, newPassword = '000000') => apiRequest(`/auth/users/${userId}/reset-password`, {
+ method: 'POST',
body: JSON.stringify({
- password: newPassword
+ new_password: newPassword
})
})
};
@@ -281,6 +284,24 @@ function checkAdminAuth() {
return user;
}
+// 페이지 접근 권한 체크 함수 (새로 추가)
+function checkPageAccess(pageName) {
+ const user = checkAuth();
+ if (!user) return null;
+
+ // admin은 모든 페이지 접근 가능
+ if (user.role === 'admin') return user;
+
+ // 페이지별 권한 체크는 pagePermissionManager에서 처리
+ if (window.pagePermissionManager && !window.pagePermissionManager.canAccessPage(pageName)) {
+ alert('이 페이지에 접근할 권한이 없습니다.');
+ window.location.href = '/index.html';
+ return null;
+ }
+
+ return user;
+}
+
// 프로젝트 API
const ProjectsAPI = {
getAll: (activeOnly = false) => {
diff --git a/frontend/static/js/auth-common.js b/frontend/static/js/auth-common.js
deleted file mode 100644
index 8443d7d..0000000
--- a/frontend/static/js/auth-common.js
+++ /dev/null
@@ -1,81 +0,0 @@
-// 공통 인증 및 네비게이션 관리
-class AuthCommon {
- static init(currentPage = '') {
- // 토큰 기반 사용자 정보 확인
- const user = TokenManager.getUser();
- if (!user) {
- window.location.href = 'index.html';
- return null;
- }
-
- // 전역 currentUser 설정
- window.currentUser = user;
-
- // 헤더 생성 (페이지별로 다른 active 상태)
- CommonHeader.init(currentPage);
-
- // 사용자 정보 표시
- this.updateUserDisplay(user);
-
- // 네비게이션 권한 업데이트
- this.updateNavigation(user);
-
- return user;
- }
-
- static updateUserDisplay(user) {
- const userDisplayElement = document.getElementById('userDisplay');
- if (userDisplayElement) {
- const displayName = user.full_name || user.username;
- userDisplayElement.textContent = `${displayName} (${user.username})`;
- }
- }
-
- static updateNavigation(user) {
- const isAdmin = user.role === 'admin';
-
- // 관리자 전용 메뉴들
- const adminMenus = [
- 'dailyWorkBtn',
- 'listBtn',
- 'summaryBtn',
- 'projectBtn'
- ];
-
- adminMenus.forEach(menuId => {
- const element = document.getElementById(menuId);
- if (element) {
- element.style.display = isAdmin ? '' : 'none';
- }
- });
-
- // 관리자 버튼 처리 (드롭다운 vs 단순 버튼)
- const adminBtnContainer = document.getElementById('adminBtnContainer');
- const userPasswordBtn = document.getElementById('userPasswordBtn');
-
- if (isAdmin) {
- // 관리자: 드롭다운 메뉴 표시
- if (adminBtnContainer) adminBtnContainer.style.display = '';
- if (userPasswordBtn) userPasswordBtn.style.display = 'none';
- } else {
- // 일반 사용자: 비밀번호 변경 버튼만 표시
- if (adminBtnContainer) adminBtnContainer.style.display = 'none';
- if (userPasswordBtn) userPasswordBtn.style.display = '';
- }
- }
-
- static logout() {
- AuthAPI.logout();
- }
-}
-
-// 전역 함수들
-function logout() {
- AuthCommon.logout();
-}
-
-function showSection(sectionName) {
- if (typeof window.showSection === 'function') {
- window.showSection(sectionName);
- }
-}
diff --git a/frontend/static/js/common-header.js b/frontend/static/js/common-header.js
deleted file mode 100644
index a0e7fdf..0000000
--- a/frontend/static/js/common-header.js
+++ /dev/null
@@ -1,137 +0,0 @@
-// 공통 헤더 생성 및 관리
-class CommonHeader {
- static create(currentPage = '') {
- return `
-
-
-
-
-
- 작업보고서
-
-
-
-
-
-
-
-
-
-
-
- `;
- }
-
- static getNavButton(href, id, iconClass, text, isActive = false) {
- const activeClass = isActive ? ' active' : '';
- return `
- ${text}
- `;
- }
-
- static getNavButtonInternal(section, id, iconClass, text, isActive = false) {
- const activeClass = isActive ? ' active' : '';
- if (section === 'list' || section === 'summary') {
- return ``;
- }
- return `
- ${text}
- `;
- }
-
- static init(currentPage = '') {
- // 헤더 HTML 삽입
- const headerContainer = document.getElementById('header-container');
- if (headerContainer) {
- headerContainer.innerHTML = this.create();
- }
-
- // 현재 페이지 활성화
- this.setActivePage(currentPage);
- }
-
- static setActivePage(currentPage) {
- // 모든 nav-link에서 active 클래스 제거
- document.querySelectorAll('.nav-link').forEach(link => {
- link.classList.remove('active');
- });
-
- // 현재 페이지에 active 클래스 추가
- const activeElement = document.getElementById(currentPage);
- if (activeElement) {
- activeElement.classList.add('active');
- }
- }
-}
-
-// 관리자 버튼 클릭 처리 (전역 함수)
-function handleAdminClick() {
- if (window.currentUser && window.currentUser.role === 'admin') {
- window.location.href = 'admin.html';
- } else {
- // 비밀번호 변경 모달 표시
- if (typeof showPasswordChangeModal === 'function') {
- showPasswordChangeModal();
- }
- }
-}
-
-// 섹션 전환 (메인 페이지용)
-function showSection(sectionName) {
- if (typeof window.showSection === 'function') {
- window.showSection(sectionName);
- }
-}
-
-// 관리자 메뉴 토글
-function toggleAdminMenu() {
- const menu = document.getElementById('adminMenu');
- if (menu) {
- menu.classList.toggle('hidden');
- }
-}
-
-// 메뉴 외부 클릭 시 닫기
-document.addEventListener('click', function(event) {
- const adminBtn = document.getElementById('adminBtn');
- const adminMenu = document.getElementById('adminMenu');
-
- if (adminBtn && adminMenu && !adminBtn.contains(event.target) && !adminMenu.contains(event.target)) {
- adminMenu.classList.add('hidden');
- }
-});
diff --git a/frontend/static/js/components/common-header.js b/frontend/static/js/components/common-header.js
new file mode 100644
index 0000000..e648476
--- /dev/null
+++ b/frontend/static/js/components/common-header.js
@@ -0,0 +1,421 @@
+/**
+ * 공통 헤더 컴포넌트
+ * 권한 기반으로 메뉴를 동적으로 생성하고 부드러운 페이지 전환을 제공
+ */
+
+class CommonHeader {
+ constructor() {
+ this.currentUser = null;
+ this.currentPage = '';
+ this.menuItems = this.initMenuItems();
+ }
+
+ /**
+ * 메뉴 아이템 정의
+ */
+ initMenuItems() {
+ return [
+ {
+ id: 'daily_work',
+ title: '일일 공수',
+ icon: 'fas fa-calendar-check',
+ url: '/daily-work.html',
+ pageName: 'daily_work',
+ color: 'text-blue-600',
+ bgColor: 'bg-blue-50 hover:bg-blue-100'
+ },
+ {
+ id: 'issues_create',
+ title: '부적합 등록',
+ icon: 'fas fa-plus-circle',
+ url: '/index.html',
+ pageName: 'issues_create',
+ color: 'text-green-600',
+ bgColor: 'bg-green-50 hover:bg-green-100'
+ },
+ {
+ id: 'issues_view',
+ title: '부적합 조회',
+ icon: 'fas fa-search',
+ url: '/issue-view.html',
+ pageName: 'issues_view',
+ color: 'text-purple-600',
+ bgColor: 'bg-purple-50 hover:bg-purple-100'
+ },
+ {
+ id: 'issues_manage',
+ title: '목록 관리',
+ icon: 'fas fa-tasks',
+ url: '/issue-view.html#manage',
+ pageName: 'issues_manage',
+ color: 'text-orange-600',
+ bgColor: 'bg-orange-50 hover:bg-orange-100'
+ },
+ {
+ id: 'reports',
+ title: '보고서',
+ icon: 'fas fa-chart-bar',
+ url: '/reports.html',
+ pageName: 'reports',
+ color: 'text-red-600',
+ bgColor: 'bg-red-50 hover:bg-red-100'
+ },
+ {
+ id: 'projects_manage',
+ title: '프로젝트 관리',
+ icon: 'fas fa-folder-open',
+ url: '/project-management.html',
+ pageName: 'projects_manage',
+ color: 'text-indigo-600',
+ bgColor: 'bg-indigo-50 hover:bg-indigo-100'
+ },
+ {
+ id: 'users_manage',
+ title: '사용자 관리',
+ icon: 'fas fa-users-cog',
+ url: '/admin.html',
+ pageName: 'users_manage',
+ color: 'text-gray-600',
+ bgColor: 'bg-gray-50 hover:bg-gray-100'
+ }
+ ];
+ }
+
+ /**
+ * 헤더 초기화
+ * @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);
+ }
+ }
+
+ /**
+ * 헤더 HTML 생성
+ */
+ generateHeaderHTML() {
+ const accessibleMenus = this.getAccessibleMenus();
+ const userDisplayName = this.currentUser?.full_name || this.currentUser?.username || '사용자';
+ const userRole = this.getUserRoleDisplay();
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${userDisplayName}
+
${userRole}
+
+
+
+ ${userDisplayName.charAt(0).toUpperCase()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ /**
+ * 접근 가능한 메뉴 필터링
+ */
+ getAccessibleMenus() {
+ return this.menuItems.filter(menu => {
+ // admin은 모든 메뉴 접근 가능
+ if (this.currentUser?.role === 'admin') {
+ return true;
+ }
+
+ // 권한 시스템이 로드되지 않았으면 기본 메뉴만
+ if (!window.canAccessPage) {
+ return ['issues_create', 'issues_view'].includes(menu.id);
+ }
+
+ return window.canAccessPage(menu.pageName);
+ });
+ }
+
+ /**
+ * 데스크톱 메뉴 아이템 HTML 생성
+ */
+ generateMenuItemHTML(menu) {
+ const isActive = this.currentPage === menu.id;
+ const activeClass = isActive ? 'bg-blue-100 text-blue-700' : `${menu.bgColor} ${menu.color}`;
+
+ return `
+
+
+ ${menu.title}
+
+ `;
+ }
+
+ /**
+ * 모바일 메뉴 아이템 HTML 생성
+ */
+ generateMobileMenuItemHTML(menu) {
+ const isActive = this.currentPage === menu.id;
+ const activeClass = isActive ? 'bg-blue-50 text-blue-700 border-blue-500' : 'text-gray-700 hover:bg-gray-50';
+
+ return `
+
+
+ ${menu.title}
+
+ `;
+ }
+
+ /**
+ * 사용자 역할 표시명 가져오기
+ */
+ 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 = `
+
+ `;
+
+ document.body.appendChild(loader);
+ }
+
+ /**
+ * 비밀번호 변경 모달 표시
+ */
+ static showPasswordModal() {
+ // 비밀번호 변경 모달 구현 (기존 코드 재사용)
+ alert('비밀번호 변경 기능은 관리자 페이지에서 이용해주세요.');
+ }
+
+ /**
+ * 로그아웃
+ */
+ static logout() {
+ if (confirm('로그아웃 하시겠습니까?')) {
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('currentUser');
+ window.location.href = '/index.html';
+ }
+ }
+
+ /**
+ * 현재 페이지 업데이트
+ */
+ updateCurrentPage(pageId) {
+ this.currentPage = pageId;
+
+ // 활성 메뉴 업데이트
+ document.querySelectorAll('.nav-item').forEach(item => {
+ const itemPageId = item.getAttribute('data-page');
+ if (itemPageId === pageId) {
+ item.classList.add('bg-blue-100', 'text-blue-700');
+ item.classList.remove('bg-blue-50', 'hover:bg-blue-100');
+ } else {
+ item.classList.remove('bg-blue-100', 'text-blue-700');
+ }
+ });
+ }
+
+ /**
+ * 키보드 단축키 초기화
+ */
+ initializeKeyboardShortcuts() {
+ if (window.keyboardShortcuts) {
+ window.keyboardShortcuts.setUser(this.currentUser);
+ console.log('⌨️ 키보드 단축키 사용자 설정 완료');
+ }
+ }
+
+ /**
+ * 페이지 프리로더 초기화
+ */
+ initializePreloader() {
+ if (window.pagePreloader) {
+ // 사용자 설정 후 프리로더 초기화
+ setTimeout(() => {
+ window.pagePreloader.init();
+ console.log('🚀 페이지 프리로더 초기화 완료');
+ }, 1000); // 권한 시스템 로드 후 실행
+ }
+ }
+}
+
+// 전역 인스턴스
+window.commonHeader = new CommonHeader();
+
+// 전역 함수로 노출
+window.CommonHeader = CommonHeader;
diff --git a/frontend/static/js/components/mobile-calendar.js b/frontend/static/js/components/mobile-calendar.js
new file mode 100644
index 0000000..9d6b808
--- /dev/null
+++ b/frontend/static/js/components/mobile-calendar.js
@@ -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 = `
+
+
+
+
+
+
+
+
+
+
일
+
월
+
화
+
수
+
목
+
금
+
토
+
+
+
+
+
+
+
+
+
+
+
+
+ 📅 날짜를 터치하여 시작일을 선택하고, 다시 터치하여 종료일을 선택하세요
+
+
+ `;
+
+ 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 += `
+
+ ${date.getDate()}
+
+ `;
+ }
+
+ 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;
diff --git a/frontend/static/js/core/keyboard-shortcuts.js b/frontend/static/js/core/keyboard-shortcuts.js
new file mode 100644
index 0000000..68816df
--- /dev/null
+++ b/frontend/static/js/core/keyboard-shortcuts.js
@@ -0,0 +1,621 @@
+/**
+ * 키보드 단축키 관리자
+ * 전역 키보드 단축키를 관리하고 사용자 경험을 향상시킵니다.
+ */
+
+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 d', () => this.navigateToPage('/daily-work.html', 'daily_work'), '일일 공수');
+ this.register('g p', () => this.navigateToPage('/project-management.html', 'projects_manage'), '프로젝트 관리');
+ this.register('g r', () => this.navigateToPage('/reports.html', 'reports'), '보고서');
+ this.register('g a', () => this.navigateToPage('/admin.html', 'users_manage'), '관리자');
+
+ // 액션 단축키
+ this.register('n', () => this.triggerNewAction(), '새 항목 생성');
+ this.register('s', () => this.triggerSaveAction(), '저장');
+ this.register('r', () => this.triggerRefreshAction(), '새로고침');
+ this.register('f', () => this.focusSearchField(), '검색 포커스');
+
+ // 관리자 전용 단축키
+ this.register('ctrl+shift+u', () => this.navigateToPage('/admin.html', 'users_manage'), '사용자 관리 (관리자)');
+
+ console.log('⌨️ 키보드 단축키 등록 완료');
+ }
+
+ /**
+ * 단축키 등록
+ * @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);
+ console.log(`⌨️ 단축키 실행: ${combination}`);
+ } 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 = `
+
+
+
+
+
+ 키보드 단축키
+
+
+
+
+
+
+
+ ${Object.entries(shortcutGroups).map(([group, items]) => `
+
+
+ ${group}
+
+
+ ${items.map(item => `
+
+
${item.description}
+
+ ${item.keys.map(key => `
+
+ ${key}
+
+ `).join('')}
+
+
+ `).join('')}
+
+
+ `).join('')}
+
+
+
+
+
+
+
사용 팁
+
+ - • 입력 필드에서는 일부 단축키만 작동합니다.
+ - • 'g' 키를 누른 후 다른 키를 눌러 페이지를 이동할 수 있습니다.
+ - • ESC 키로 모달이나 메뉴를 닫을 수 있습니다.
+ - • '?' 키로 언제든 이 도움말을 볼 수 있습니다.
+
+
+
+
+
+
+ `;
+
+ 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;
+ console.log(`⌨️ 키보드 단축키 ${enabled ? '활성화' : '비활성화'}`);
+ }
+
+ /**
+ * 단축키 제거
+ */
+ unregister(combination) {
+ const normalizedCombo = this.normalizeKeyCombination(combination);
+ return this.shortcuts.delete(normalizedCombo);
+ }
+}
+
+// 전역 인스턴스
+window.keyboardShortcuts = new KeyboardShortcutManager();
diff --git a/frontend/static/js/core/page-manager.js b/frontend/static/js/core/page-manager.js
new file mode 100644
index 0000000..2c6d2c1
--- /dev/null
+++ b/frontend/static/js/core/page-manager.js
@@ -0,0 +1,368 @@
+/**
+ * 페이지 관리자
+ * 모듈화된 페이지들의 생명주기를 관리하고 부드러운 전환을 제공
+ */
+
+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('access_token');
+ if (!token) {
+ window.location.href = '/index.html';
+ return null;
+ }
+
+ try {
+ // API가 로드될 때까지 대기
+ await this.waitForAPI();
+
+ const user = await AuthAPI.getCurrentUser();
+ localStorage.setItem('currentUser', JSON.stringify(user));
+ return user;
+ } catch (error) {
+ console.error('인증 실패:', error);
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('currentUser');
+ 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 'projects_manage':
+ return new ProjectsManageModule(options);
+ case 'daily_work':
+ return new DailyWorkModule(options);
+ case 'reports':
+ return new ReportsModule(options);
+ case 'users_manage':
+ return new UsersManageModule(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 = `
+
+
+
페이지를 로드하는 중...
+
잠시만 기다려주세요
+
+ `;
+
+ 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 = `
+
+
+
+
+
페이지 로드 실패
+
${error.message || '알 수 없는 오류가 발생했습니다.'}
+
+
+
+
+
+ `;
+
+ 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 = `
+
+ `;
+ }
+ }
+
+ /**
+ * 에러 표시
+ */
+ showError(container, message = '오류가 발생했습니다.') {
+ if (typeof container === 'string') {
+ container = document.getElementById(container);
+ }
+
+ if (container) {
+ container.innerHTML = `
+
+ `;
+ }
+ }
+}
+
+// 전역 인스턴스
+window.pageManager = new PageManager();
+window.BasePageModule = BasePageModule;
diff --git a/frontend/static/js/core/page-preloader.js b/frontend/static/js/core/page-preloader.js
new file mode 100644
index 0000000..7d89eb8
--- /dev/null
+++ b/frontend/static/js/core/page-preloader.js
@@ -0,0 +1,317 @@
+/**
+ * 페이지 프리로더
+ * 사용자가 방문할 가능성이 높은 페이지들을 미리 로드하여 성능 향상
+ */
+
+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.registerServiceWorker();
+ }
+
+ /**
+ * 우선순위 기반 프리로딩 스케줄링
+ */
+ 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: '/issue-view.html#manage', priority: 2 },
+ { id: 'projects_manage', url: '/project-management.html', priority: 3 },
+ { id: 'daily_work', url: '/daily-work.html', priority: 2 },
+ { id: 'reports', url: '/reports.html', priority: 3 },
+ { id: 'users_manage', url: '/admin.html', priority: 4 }
+ ];
+
+ // 권한 체크
+ 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;
+ console.log('🚀 페이지 프리로딩 시작:', pages.map(p => p.id));
+
+ for (const page of pages) {
+ if (this.preloadedPages.has(page.url)) continue;
+
+ try {
+ await this.preloadPage(page);
+
+ // 네트워크 상태 확인 (느린 연결에서는 중단)
+ if (this.isSlowConnection()) {
+ console.log('⚠️ 느린 연결 감지, 프리로딩 중단');
+ break;
+ }
+
+ // CPU 부하 방지를 위한 딜레이
+ await this.delay(500);
+
+ } catch (error) {
+ console.warn('프리로딩 실패:', page.id, error);
+ }
+ }
+
+ this.isPreloading = false;
+ console.log('✅ 페이지 프리로딩 완료');
+ }
+
+ /**
+ * 개별 페이지 프리로드
+ */
+ 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);
+ console.log(`📄 프리로드 완료: ${page.id}`);
+ }
+
+ } 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);
+ console.log('🖱️ 호버 프리로드 완료:', 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 registerServiceWorker() {
+ if ('serviceWorker' in navigator) {
+ try {
+ const registration = await navigator.serviceWorker.register('/sw.js');
+ console.log('🔧 서비스 워커 등록 완료:', registration);
+ } catch (error) {
+ console.log('서비스 워커 등록 실패:', error);
+ }
+ }
+ }
+
+ /**
+ * 프리로드된 페이지 가져오기
+ */
+ getPreloadedPage(url) {
+ return this.preloadCache.get(url);
+ }
+
+ /**
+ * 캐시 정리
+ */
+ clearCache() {
+ this.preloadCache.clear();
+ this.resourceCache.clear();
+ this.preloadedPages.clear();
+ console.log('🗑️ 프리로드 캐시 정리 완료');
+ }
+}
+
+// 전역 인스턴스
+window.pagePreloader = new PagePreloader();
diff --git a/frontend/static/js/core/permissions.js b/frontend/static/js/core/permissions.js
new file mode 100644
index 0000000..540a2a1
--- /dev/null
+++ b/frontend/static/js/core/permissions.js
@@ -0,0 +1,260 @@
+/**
+ * 단순화된 페이지 권한 관리 시스템
+ * admin/user 구조에서 페이지별 접근 권한을 관리
+ */
+
+class PagePermissionManager {
+ constructor() {
+ this.currentUser = null;
+ this.pagePermissions = new Map();
+ this.defaultPages = this.initDefaultPages();
+ }
+
+ /**
+ * 기본 페이지 목록 초기화
+ */
+ initDefaultPages() {
+ return {
+ 'issues_create': { title: '부적합 등록', defaultAccess: true },
+ 'issues_view': { title: '부적합 조회', defaultAccess: true },
+ 'issues_manage': { title: '부적합 관리', defaultAccess: false },
+ 'projects_manage': { title: '프로젝트 관리', defaultAccess: false },
+ 'daily_work': { title: '일일 공수', defaultAccess: false },
+ 'reports': { title: '보고서', defaultAccess: false },
+ 'users_manage': { title: '사용자 관리', defaultAccess: false }
+ };
+ }
+
+ /**
+ * 사용자 설정
+ * @param {Object} user - 사용자 객체
+ */
+ setUser(user) {
+ this.currentUser = user;
+ this.loadPagePermissions();
+ }
+
+ /**
+ * 사용자별 페이지 권한 로드
+ */
+ async loadPagePermissions() {
+ if (!this.currentUser) return;
+
+ try {
+ // API에서 사용자별 페이지 권한 가져오기
+ const response = await fetch(`/api/users/${this.currentUser.id}/page-permissions`, {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('access_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: 'projects_manage',
+ title: '프로젝트 관리',
+ icon: 'fas fa-folder-open',
+ path: '#projects/manage',
+ pageName: 'projects_manage'
+ },
+ {
+ id: 'daily_work',
+ title: '일일 공수',
+ icon: 'fas fa-calendar-check',
+ path: '#daily-work',
+ pageName: 'daily_work'
+ },
+ {
+ id: 'reports',
+ title: '보고서',
+ icon: 'fas fa-chart-bar',
+ path: '#reports',
+ pageName: 'reports'
+ },
+ {
+ id: 'users_manage',
+ title: '사용자 관리',
+ icon: 'fas fa-users-cog',
+ path: '#users/manage',
+ pageName: 'users_manage'
+ }
+ ];
+
+ // 페이지 권한에 따라 메뉴 필터링
+ 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 response = await fetch('/api/page-permissions/grant', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('access_token')}`
+ },
+ 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 response = await fetch(`/api/users/${userId}/page-permissions`, {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('access_token')}`
+ }
+ });
+
+ 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);
diff --git a/frontend/sw.js b/frontend/sw.js
new file mode 100644
index 0000000..ddbc9ff
--- /dev/null
+++ b/frontend/sw.js
@@ -0,0 +1,335 @@
+/**
+ * 서비스 워커 - 페이지 및 리소스 캐싱
+ * M-Project 작업보고서 시스템
+ */
+
+const CACHE_NAME = 'mproject-v1.0.0';
+const STATIC_CACHE = 'mproject-static-v1.0.0';
+const DYNAMIC_CACHE = 'mproject-dynamic-v1.0.0';
+
+// 캐시할 정적 리소스
+const STATIC_ASSETS = [
+ '/',
+ '/index.html',
+ '/issue-view.html',
+ '/daily-work.html',
+ '/project-management.html',
+ '/admin.html',
+ '/static/js/api.js',
+ '/static/js/core/permissions.js',
+ '/static/js/components/common-header.js',
+ '/static/js/core/page-manager.js',
+ '/static/js/core/page-preloader.js',
+ '/static/js/date-utils.js',
+ '/static/js/image-utils.js',
+ 'https://cdn.tailwindcss.com',
+ 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css'
+];
+
+// 캐시 전략별 URL 패턴
+const CACHE_STRATEGIES = {
+ // 네트워크 우선 (API 호출)
+ networkFirst: [
+ /\/api\//,
+ /\/auth\//
+ ],
+
+ // 캐시 우선 (정적 리소스)
+ cacheFirst: [
+ /\.css$/,
+ /\.js$/,
+ /\.png$/,
+ /\.jpg$/,
+ /\.jpeg$/,
+ /\.gif$/,
+ /\.svg$/,
+ /\.woff$/,
+ /\.woff2$/,
+ /cdn\.tailwindcss\.com/,
+ /cdnjs\.cloudflare\.com/
+ ],
+
+ // 스테일 허용 (HTML 페이지)
+ staleWhileRevalidate: [
+ /\.html$/,
+ /\/$/
+ ]
+};
+
+/**
+ * 서비스 워커 설치
+ */
+self.addEventListener('install', (event) => {
+ console.log('🔧 서비스 워커 설치 중...');
+
+ event.waitUntil(
+ caches.open(STATIC_CACHE)
+ .then((cache) => {
+ console.log('📦 정적 리소스 캐싱 중...');
+ return cache.addAll(STATIC_ASSETS);
+ })
+ .then(() => {
+ console.log('✅ 서비스 워커 설치 완료');
+ return self.skipWaiting();
+ })
+ .catch((error) => {
+ console.error('❌ 서비스 워커 설치 실패:', error);
+ })
+ );
+});
+
+/**
+ * 서비스 워커 활성화
+ */
+self.addEventListener('activate', (event) => {
+ console.log('🚀 서비스 워커 활성화 중...');
+
+ event.waitUntil(
+ caches.keys()
+ .then((cacheNames) => {
+ return Promise.all(
+ cacheNames.map((cacheName) => {
+ // 이전 버전 캐시 삭제
+ if (cacheName !== STATIC_CACHE &&
+ cacheName !== DYNAMIC_CACHE &&
+ cacheName !== CACHE_NAME) {
+ console.log('🗑️ 이전 캐시 삭제:', cacheName);
+ return caches.delete(cacheName);
+ }
+ })
+ );
+ })
+ .then(() => {
+ console.log('✅ 서비스 워커 활성화 완료');
+ return self.clients.claim();
+ })
+ );
+});
+
+/**
+ * 네트워크 요청 가로채기
+ */
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ // CORS 요청이나 외부 도메인은 기본 처리
+ if (url.origin !== location.origin && !isCDNResource(url)) {
+ return;
+ }
+
+ // 캐시 전략 결정
+ const strategy = getCacheStrategy(request.url);
+
+ event.respondWith(
+ handleRequest(request, strategy)
+ );
+});
+
+/**
+ * 요청 처리 (캐시 전략별)
+ */
+async function handleRequest(request, strategy) {
+ try {
+ switch (strategy) {
+ case 'networkFirst':
+ return await networkFirst(request);
+ case 'cacheFirst':
+ return await cacheFirst(request);
+ case 'staleWhileRevalidate':
+ return await staleWhileRevalidate(request);
+ default:
+ return await fetch(request);
+ }
+ } catch (error) {
+ console.error('요청 처리 실패:', request.url, error);
+ return await handleOffline(request);
+ }
+}
+
+/**
+ * 네트워크 우선 전략
+ */
+async function networkFirst(request) {
+ try {
+ const networkResponse = await fetch(request);
+
+ // 성공적인 응답만 캐시
+ if (networkResponse.ok) {
+ const cache = await caches.open(DYNAMIC_CACHE);
+ cache.put(request, networkResponse.clone());
+ }
+
+ return networkResponse;
+ } catch (error) {
+ // 네트워크 실패 시 캐시에서 반환
+ const cachedResponse = await caches.match(request);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+ throw error;
+ }
+}
+
+/**
+ * 캐시 우선 전략
+ */
+async function cacheFirst(request) {
+ const cachedResponse = await caches.match(request);
+
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+
+ // 캐시에 없으면 네트워크에서 가져와서 캐시
+ const networkResponse = await fetch(request);
+
+ if (networkResponse.ok) {
+ const cache = await caches.open(STATIC_CACHE);
+ cache.put(request, networkResponse.clone());
+ }
+
+ return networkResponse;
+}
+
+/**
+ * 스테일 허용 전략
+ */
+async function staleWhileRevalidate(request) {
+ const cachedResponse = await caches.match(request);
+
+ // 백그라운드에서 업데이트
+ const networkResponsePromise = fetch(request)
+ .then((networkResponse) => {
+ if (networkResponse.ok) {
+ const cache = caches.open(DYNAMIC_CACHE);
+ cache.then(c => c.put(request, networkResponse.clone()));
+ }
+ return networkResponse;
+ })
+ .catch(() => null);
+
+ // 캐시된 응답이 있으면 즉시 반환, 없으면 네트워크 대기
+ return cachedResponse || await networkResponsePromise;
+}
+
+/**
+ * 캐시 전략 결정
+ */
+function getCacheStrategy(url) {
+ for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) {
+ if (patterns.some(pattern => pattern.test(url))) {
+ return strategy;
+ }
+ }
+ return 'networkFirst'; // 기본값
+}
+
+/**
+ * CDN 리소스 확인
+ */
+function isCDNResource(url) {
+ const cdnDomains = [
+ 'cdn.tailwindcss.com',
+ 'cdnjs.cloudflare.com',
+ 'fonts.googleapis.com',
+ 'fonts.gstatic.com'
+ ];
+
+ return cdnDomains.some(domain => url.hostname.includes(domain));
+}
+
+/**
+ * 오프라인 처리
+ */
+async function handleOffline(request) {
+ // HTML 요청에 대한 오프라인 페이지
+ if (request.destination === 'document') {
+ const offlinePage = await caches.match('/index.html');
+ if (offlinePage) {
+ return offlinePage;
+ }
+ }
+
+ // 이미지 요청에 대한 기본 이미지
+ if (request.destination === 'image') {
+ return new Response(
+ '',
+ { headers: { 'Content-Type': 'image/svg+xml' } }
+ );
+ }
+
+ // 기본 오프라인 응답
+ return new Response('오프라인 상태입니다.', {
+ status: 503,
+ statusText: 'Service Unavailable',
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' }
+ });
+}
+
+/**
+ * 메시지 처리 (캐시 관리)
+ */
+self.addEventListener('message', (event) => {
+ const { type, payload } = event.data;
+
+ switch (type) {
+ case 'CLEAR_CACHE':
+ clearAllCaches().then(() => {
+ event.ports[0].postMessage({ success: true });
+ });
+ break;
+
+ case 'CACHE_PAGE':
+ cachePage(payload.url).then(() => {
+ event.ports[0].postMessage({ success: true });
+ });
+ break;
+
+ case 'GET_CACHE_STATUS':
+ getCacheStatus().then((status) => {
+ event.ports[0].postMessage({ status });
+ });
+ break;
+ }
+});
+
+/**
+ * 모든 캐시 정리
+ */
+async function clearAllCaches() {
+ const cacheNames = await caches.keys();
+ await Promise.all(
+ cacheNames.map(cacheName => caches.delete(cacheName))
+ );
+ console.log('🗑️ 모든 캐시 정리 완료');
+}
+
+/**
+ * 특정 페이지 캐시
+ */
+async function cachePage(url) {
+ try {
+ const cache = await caches.open(DYNAMIC_CACHE);
+ await cache.add(url);
+ console.log('📦 페이지 캐시 완료:', url);
+ } catch (error) {
+ console.error('페이지 캐시 실패:', url, error);
+ }
+}
+
+/**
+ * 캐시 상태 조회
+ */
+async function getCacheStatus() {
+ const cacheNames = await caches.keys();
+ const status = {};
+
+ for (const cacheName of cacheNames) {
+ const cache = await caches.open(cacheName);
+ const keys = await cache.keys();
+ status[cacheName] = keys.length;
+ }
+
+ return status;
+}