- 권한 기반 공통 헤더 컴포넌트 구현 - 모바일 친화적 캘린더 날짜 필터 추가 - 페이지 프리로더 및 키보드 단축키 시스템 구현 - Service Worker 기반 캐싱 시스템 추가 Frontend Changes: - components/common-header.js: 권한 기반 동적 메뉴 생성 - components/mobile-calendar.js: 터치/스와이프 지원 캘린더 - core/permissions.js: 페이지 접근 권한 관리 - core/page-manager.js: 페이지 라이프사이클 관리 - core/page-preloader.js: 페이지 프리로딩 최적화 - core/keyboard-shortcuts.js: 키보드 네비게이션 - css/mobile-calendar.css: 모바일 최적화 캘린더 스타일 - sw.js: 3단계 캐싱 전략 서비스 워커 Removed: - auth-common.js, common-header.js (구버전 파일들)
622 lines
20 KiB
JavaScript
622 lines
20 KiB
JavaScript
/**
|
|
* 키보드 단축키 관리자
|
|
* 전역 키보드 단축키를 관리하고 사용자 경험을 향상시킵니다.
|
|
*/
|
|
|
|
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 = `
|
|
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
|
<div class="p-6 border-b border-gray-200">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-2xl font-bold text-gray-900">
|
|
<i class="fas fa-keyboard mr-3 text-blue-600"></i>
|
|
키보드 단축키
|
|
</h2>
|
|
<button onclick="keyboardShortcuts.hideHelpModal()"
|
|
class="text-gray-400 hover:text-gray-600 text-2xl">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-6">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
${Object.entries(shortcutGroups).map(([group, items]) => `
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-800 mb-4 border-b pb-2">
|
|
${group}
|
|
</h3>
|
|
<div class="space-y-3">
|
|
${items.map(item => `
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-gray-600">${item.description}</span>
|
|
<div class="flex space-x-1">
|
|
${item.keys.map(key => `
|
|
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">
|
|
${key}
|
|
</kbd>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div class="mt-8 p-4 bg-blue-50 rounded-lg">
|
|
<div class="flex items-start">
|
|
<i class="fas fa-info-circle text-blue-600 mt-1 mr-3"></i>
|
|
<div>
|
|
<h4 class="font-semibold text-blue-900 mb-2">사용 팁</h4>
|
|
<ul class="text-blue-800 text-sm space-y-1">
|
|
<li>• 입력 필드에서는 일부 단축키만 작동합니다.</li>
|
|
<li>• 'g' 키를 누른 후 다른 키를 눌러 페이지를 이동할 수 있습니다.</li>
|
|
<li>• ESC 키로 모달이나 메뉴를 닫을 수 있습니다.</li>
|
|
<li>• '?' 키로 언제든 이 도움말을 볼 수 있습니다.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
return modal;
|
|
}
|
|
|
|
/**
|
|
* 사용 가능한 단축키 가져오기
|
|
*/
|
|
getAvailableShortcuts() {
|
|
const available = [];
|
|
|
|
for (const [combination, shortcut] of this.shortcuts) {
|
|
// 권한 확인
|
|
if (shortcut.requiresAuth && !this.currentUser) continue;
|
|
if (shortcut.adminOnly && this.currentUser?.role !== 'admin') continue;
|
|
|
|
available.push({
|
|
combination,
|
|
description: shortcut.description,
|
|
keys: this.formatKeyCombo(combination)
|
|
});
|
|
}
|
|
|
|
return available;
|
|
}
|
|
|
|
/**
|
|
* 단축키 그룹화
|
|
*/
|
|
groupShortcuts(shortcuts) {
|
|
const groups = {
|
|
'네비게이션': [],
|
|
'액션': [],
|
|
'전역': []
|
|
};
|
|
|
|
shortcuts.forEach(shortcut => {
|
|
if (shortcut.combination.startsWith('g ')) {
|
|
groups['네비게이션'].push(shortcut);
|
|
} else if (['n', 's', 'r', 'f'].includes(shortcut.combination)) {
|
|
groups['액션'].push(shortcut);
|
|
} else {
|
|
groups['전역'].push(shortcut);
|
|
}
|
|
});
|
|
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* 키 조합 포맷팅
|
|
*/
|
|
formatKeyCombo(combination) {
|
|
return combination
|
|
.split(' ')
|
|
.map(part => {
|
|
return part
|
|
.split('+')
|
|
.map(key => {
|
|
const keyNames = {
|
|
'ctrl': 'Ctrl',
|
|
'alt': 'Alt',
|
|
'shift': 'Shift',
|
|
'meta': 'Cmd',
|
|
'space': 'Space',
|
|
'enter': 'Enter',
|
|
'escape': 'Esc',
|
|
'tab': 'Tab'
|
|
};
|
|
return keyNames[key] || key.toUpperCase();
|
|
})
|
|
.join(' + ');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 도움말 모달 숨기기
|
|
*/
|
|
hideHelpModal() {
|
|
const modal = document.getElementById('keyboard-shortcuts-modal');
|
|
if (modal) {
|
|
modal.remove();
|
|
this.helpModalVisible = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 알림 표시
|
|
*/
|
|
showNotification(message, type = 'info') {
|
|
// 기존 알림 제거
|
|
const existing = document.getElementById('shortcut-notification');
|
|
if (existing) existing.remove();
|
|
|
|
const notification = document.createElement('div');
|
|
notification.id = 'shortcut-notification';
|
|
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg shadow-lg z-50 transition-all duration-300 ${this.getNotificationClass(type)}`;
|
|
notification.textContent = message;
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
// 3초 후 자동 제거
|
|
setTimeout(() => {
|
|
if (notification.parentNode) {
|
|
notification.remove();
|
|
}
|
|
}, 3000);
|
|
}
|
|
|
|
/**
|
|
* 알림 클래스 가져오기
|
|
*/
|
|
getNotificationClass(type) {
|
|
const classes = {
|
|
'info': 'bg-blue-600 text-white',
|
|
'success': 'bg-green-600 text-white',
|
|
'warning': 'bg-yellow-600 text-white',
|
|
'error': 'bg-red-600 text-white'
|
|
};
|
|
return classes[type] || classes.info;
|
|
}
|
|
|
|
/**
|
|
* 사용자 설정
|
|
*/
|
|
setUser(user) {
|
|
this.currentUser = user;
|
|
}
|
|
|
|
/**
|
|
* 단축키 활성화/비활성화
|
|
*/
|
|
setEnabled(enabled) {
|
|
this.isEnabled = enabled;
|
|
console.log(`⌨️ 키보드 단축키 ${enabled ? '활성화' : '비활성화'}`);
|
|
}
|
|
|
|
/**
|
|
* 단축키 제거
|
|
*/
|
|
unregister(combination) {
|
|
const normalizedCombo = this.normalizeKeyCombination(combination);
|
|
return this.shortcuts.delete(normalizedCombo);
|
|
}
|
|
}
|
|
|
|
// 전역 인스턴스
|
|
window.keyboardShortcuts = new KeyboardShortcutManager();
|