feat: 3-System 분리 프로젝트 초기 코드 작성
TK-FB(공장관리+신고)와 M-Project(부적합관리)를 3개 독립 시스템으로 분리하기 위한 전체 코드 구조 작성. - SSO 인증 서비스 (bcrypt + pbkdf2 이중 해시 지원) - System 1: 공장관리 (TK-FB 기반, 신고 코드 제거) - System 2: 신고 (TK-FB에서 workIssue 코드 추출) - System 3: 부적합관리 (M-Project 기반) - Gateway 포털 (path-based 라우팅) - 통합 docker-compose.yml 및 배포 스크립트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
346
system3-nonconformance/web/static/js/api.js
Normal file
346
system3-nonconformance/web/static/js/api.js
Normal file
@@ -0,0 +1,346 @@
|
||||
// API 기본 설정 (Cloudflare 터널 + 로컬 환경 지원)
|
||||
const API_BASE_URL = (() => {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
|
||||
console.log('🔧 API URL 생성 - hostname:', hostname, 'protocol:', protocol, 'port:', port);
|
||||
|
||||
// 로컬 환경 (포트 있음)
|
||||
if (port === '16080') {
|
||||
const url = `${protocol}//${hostname}:${port}/api`;
|
||||
console.log('🏠 로컬 환경 URL:', url);
|
||||
return url;
|
||||
}
|
||||
|
||||
// Cloudflare 터널 환경 (m.hyungi.net) - 강제 HTTPS
|
||||
if (hostname === 'm.hyungi.net') {
|
||||
const url = `https://m-api.hyungi.net/api`;
|
||||
console.log('☁️ Cloudflare 환경 URL:', url);
|
||||
return url;
|
||||
}
|
||||
|
||||
// 기타 환경
|
||||
const url = '/api';
|
||||
console.log('🌐 기타 환경 URL:', url);
|
||||
return url;
|
||||
})();
|
||||
|
||||
// 토큰 관리
|
||||
const TokenManager = {
|
||||
getToken: () => localStorage.getItem('access_token'),
|
||||
setToken: (token) => localStorage.setItem('access_token', token),
|
||||
removeToken: () => localStorage.removeItem('access_token'),
|
||||
|
||||
getUser: () => {
|
||||
const userStr = localStorage.getItem('current_user');
|
||||
return userStr ? JSON.parse(userStr) : null;
|
||||
},
|
||||
setUser: (user) => localStorage.setItem('current_user', JSON.stringify(user)),
|
||||
removeUser: () => localStorage.removeItem('current_user')
|
||||
};
|
||||
|
||||
// API 요청 헬퍼
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
|
||||
|
||||
if (response.status === 401) {
|
||||
// 인증 실패 시 로그인 페이지로
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('API Error Response:', error);
|
||||
console.error('Error details:', JSON.stringify(error, null, 2));
|
||||
|
||||
// 422 에러의 경우 validation 에러 메시지 추출
|
||||
if (response.status === 422 && error.detail && Array.isArray(error.detail)) {
|
||||
const validationErrors = error.detail.map(err =>
|
||||
`${err.loc ? err.loc.join('.') : 'field'}: ${err.msg}`
|
||||
).join(', ');
|
||||
throw new Error(`입력값 검증 오류: ${validationErrors}`);
|
||||
}
|
||||
|
||||
throw new Error(error.detail || 'API 요청 실패');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API 요청 에러:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth API
|
||||
const AuthAPI = {
|
||||
login: async (username, password) => {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: formData.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('로그인 에러:', error);
|
||||
throw new Error(error.detail || '로그인 실패');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
TokenManager.setToken(data.access_token);
|
||||
TokenManager.setUser(data.user);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('로그인 요청 에러:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = '/index.html';
|
||||
},
|
||||
|
||||
getMe: () => apiRequest('/auth/me'),
|
||||
|
||||
getCurrentUser: () => apiRequest('/auth/me'),
|
||||
|
||||
createUser: (userData) => apiRequest('/auth/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData)
|
||||
}),
|
||||
|
||||
getUsers: () => {
|
||||
console.log('🔍 AuthAPI.getUsers 호출 - 엔드포인트: /auth/users');
|
||||
return apiRequest('/auth/users');
|
||||
},
|
||||
|
||||
updateUser: (userId, userData) => apiRequest(`/auth/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(userData)
|
||||
}),
|
||||
|
||||
deleteUser: (userId) => apiRequest(`/auth/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
|
||||
changePassword: (currentPassword, newPassword) => apiRequest('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
}),
|
||||
|
||||
resetPassword: (userId, newPassword = '000000') => apiRequest(`/auth/users/${userId}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
new_password: newPassword
|
||||
})
|
||||
}),
|
||||
|
||||
// 부서 목록 가져오기
|
||||
getDepartments: () => [
|
||||
{ value: 'production', label: '생산' },
|
||||
{ value: 'quality', label: '품질' },
|
||||
{ value: 'purchasing', label: '구매' },
|
||||
{ value: 'design', label: '설계' },
|
||||
{ value: 'sales', label: '영업' }
|
||||
],
|
||||
|
||||
// 부서명 변환
|
||||
getDepartmentLabel: (departmentValue) => {
|
||||
const departments = AuthAPI.getDepartments();
|
||||
const dept = departments.find(d => d.value === departmentValue);
|
||||
return dept ? dept.label : departmentValue || '미지정';
|
||||
}
|
||||
};
|
||||
|
||||
// Issues API
|
||||
const IssuesAPI = {
|
||||
create: async (issueData) => {
|
||||
// photos 배열 처리 (최대 5장)
|
||||
const dataToSend = {
|
||||
category: issueData.category,
|
||||
description: issueData.description,
|
||||
project_id: issueData.project_id,
|
||||
photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null,
|
||||
photo2: issueData.photos && issueData.photos.length > 1 ? issueData.photos[1] : null,
|
||||
photo3: issueData.photos && issueData.photos.length > 2 ? issueData.photos[2] : null,
|
||||
photo4: issueData.photos && issueData.photos.length > 3 ? issueData.photos[3] : null,
|
||||
photo5: issueData.photos && issueData.photos.length > 4 ? issueData.photos[4] : null
|
||||
};
|
||||
|
||||
return apiRequest('/issues/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dataToSend)
|
||||
});
|
||||
},
|
||||
|
||||
getAll: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return apiRequest(`/issues/${queryString ? '?' + queryString : ''}`);
|
||||
},
|
||||
|
||||
get: (id) => apiRequest(`/issues/${id}`),
|
||||
|
||||
update: (id, issueData) => apiRequest(`/issues/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(issueData)
|
||||
}),
|
||||
|
||||
delete: (id) => apiRequest(`/issues/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
|
||||
getStats: () => apiRequest('/issues/stats/summary')
|
||||
};
|
||||
|
||||
// Daily Work API
|
||||
const DailyWorkAPI = {
|
||||
create: (workData) => apiRequest('/daily-work/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(workData)
|
||||
}),
|
||||
|
||||
getAll: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return apiRequest(`/daily-work/${queryString ? '?' + queryString : ''}`);
|
||||
},
|
||||
|
||||
get: (id) => apiRequest(`/daily-work/${id}`),
|
||||
|
||||
update: (id, workData) => apiRequest(`/daily-work/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(workData)
|
||||
}),
|
||||
|
||||
delete: (id) => apiRequest(`/daily-work/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
|
||||
getStats: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return apiRequest(`/daily-work/stats/summary${queryString ? '?' + queryString : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Reports API
|
||||
const ReportsAPI = {
|
||||
getSummary: (startDate, endDate) => apiRequest('/reports/summary', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
})
|
||||
}),
|
||||
|
||||
getIssues: (startDate, endDate) => {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}).toString();
|
||||
return apiRequest(`/reports/issues?${params}`);
|
||||
},
|
||||
|
||||
getDailyWorks: (startDate, endDate) => {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}).toString();
|
||||
return apiRequest(`/reports/daily-works?${params}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 권한 체크
|
||||
function checkAuth() {
|
||||
const user = TokenManager.getUser();
|
||||
if (!user) {
|
||||
window.location.href = '/index.html';
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function checkAdminAuth() {
|
||||
const user = checkAuth();
|
||||
if (user && user.role !== 'admin') {
|
||||
alert('관리자 권한이 필요합니다.');
|
||||
window.location.href = '/index.html';
|
||||
return null;
|
||||
}
|
||||
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) => {
|
||||
const params = `?active_only=${activeOnly}`;
|
||||
return apiRequest(`/projects/${params}`);
|
||||
},
|
||||
|
||||
get: (id) => apiRequest(`/projects/${id}`),
|
||||
|
||||
create: (projectData) => apiRequest('/projects/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(projectData)
|
||||
}),
|
||||
|
||||
update: (id, projectData) => apiRequest(`/projects/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(projectData)
|
||||
}),
|
||||
|
||||
delete: (id) => apiRequest(`/projects/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
};
|
||||
441
system3-nonconformance/web/static/js/app.js
Normal file
441
system3-nonconformance/web/static/js/app.js
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* 메인 애플리케이션 JavaScript
|
||||
* 통합된 SPA 애플리케이션의 핵심 로직
|
||||
*/
|
||||
|
||||
class App {
|
||||
constructor() {
|
||||
this.currentUser = null;
|
||||
this.currentPage = 'dashboard';
|
||||
this.modules = new Map();
|
||||
this.sidebarCollapsed = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플리케이션 초기화
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
// 인증 확인
|
||||
await this.checkAuth();
|
||||
|
||||
// API 스크립트 로드
|
||||
await this.loadAPIScript();
|
||||
|
||||
// 권한 시스템 초기화
|
||||
window.pagePermissionManager.setUser(this.currentUser);
|
||||
|
||||
// UI 초기화
|
||||
this.initializeUI();
|
||||
|
||||
// 라우터 초기화
|
||||
this.initializeRouter();
|
||||
|
||||
// 대시보드 데이터 로드
|
||||
await this.loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('앱 초기화 실패:', error);
|
||||
this.redirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 확인
|
||||
*/
|
||||
async checkAuth() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
throw new Error('토큰 없음');
|
||||
}
|
||||
|
||||
// 임시로 localStorage에서 사용자 정보 가져오기
|
||||
const storedUser = localStorage.getItem('currentUser');
|
||||
if (storedUser) {
|
||||
this.currentUser = JSON.parse(storedUser);
|
||||
} else {
|
||||
throw new Error('사용자 정보 없음');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 스크립트 동적 로드
|
||||
*/
|
||||
async loadAPIScript() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `/static/js/api.js?v=${Date.now()}`;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 초기화
|
||||
*/
|
||||
initializeUI() {
|
||||
// 사용자 정보 표시
|
||||
this.updateUserDisplay();
|
||||
|
||||
// 네비게이션 메뉴 생성
|
||||
this.createNavigationMenu();
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
this.registerEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 정보 표시 업데이트
|
||||
*/
|
||||
updateUserDisplay() {
|
||||
const userInitial = document.getElementById('userInitial');
|
||||
const userDisplayName = document.getElementById('userDisplayName');
|
||||
const userRole = document.getElementById('userRole');
|
||||
|
||||
const displayName = this.currentUser.full_name || this.currentUser.username;
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
|
||||
userInitial.textContent = initial;
|
||||
userDisplayName.textContent = displayName;
|
||||
userRole.textContent = this.getRoleDisplayName(this.currentUser.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할 표시명 가져오기
|
||||
*/
|
||||
getRoleDisplayName(role) {
|
||||
const roleNames = {
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleNames[role] || role;
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 메뉴 생성
|
||||
*/
|
||||
createNavigationMenu() {
|
||||
const menuConfig = window.pagePermissionManager.getMenuConfig();
|
||||
const navigationMenu = document.getElementById('navigationMenu');
|
||||
|
||||
navigationMenu.innerHTML = '';
|
||||
|
||||
menuConfig.forEach(item => {
|
||||
const menuItem = this.createMenuItem(item);
|
||||
navigationMenu.appendChild(menuItem);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 아이템 생성
|
||||
*/
|
||||
createMenuItem(item) {
|
||||
const li = document.createElement('li');
|
||||
|
||||
// 단순한 단일 메뉴 아이템만 지원
|
||||
li.innerHTML = `
|
||||
<div class="nav-item p-3 rounded-lg cursor-pointer" onclick="app.navigateTo('${item.path}')">
|
||||
<div class="flex items-center">
|
||||
<i class="${item.icon} mr-3 text-gray-500"></i>
|
||||
<span class="text-gray-700">${item.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 라우터 초기화
|
||||
*/
|
||||
initializeRouter() {
|
||||
// 해시 변경 감지
|
||||
window.addEventListener('hashchange', () => {
|
||||
this.handleRouteChange();
|
||||
});
|
||||
|
||||
// 초기 라우트 처리
|
||||
this.handleRouteChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 변경 처리
|
||||
*/
|
||||
async handleRouteChange() {
|
||||
const hash = window.location.hash.substring(1) || 'dashboard';
|
||||
const [module, action] = hash.split('/');
|
||||
|
||||
try {
|
||||
await this.loadModule(module, action);
|
||||
this.updateActiveNavigation(hash);
|
||||
this.updatePageTitle(module, action);
|
||||
} catch (error) {
|
||||
console.error('라우트 처리 실패:', error);
|
||||
this.showError('페이지를 로드할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 로드
|
||||
*/
|
||||
async loadModule(module, action = 'list') {
|
||||
if (module === 'dashboard') {
|
||||
this.showDashboard();
|
||||
return;
|
||||
}
|
||||
|
||||
// 모듈이 이미 로드되어 있는지 확인
|
||||
if (!this.modules.has(module)) {
|
||||
await this.loadModuleScript(module);
|
||||
}
|
||||
|
||||
// 모듈 실행
|
||||
const moduleInstance = this.modules.get(module);
|
||||
if (moduleInstance && typeof moduleInstance.render === 'function') {
|
||||
const content = await moduleInstance.render(action);
|
||||
this.showDynamicContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 스크립트 로드
|
||||
*/
|
||||
async loadModuleScript(module) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `/static/js/modules/${module}/${module}.js?v=${Date.now()}`;
|
||||
script.onload = () => {
|
||||
// 모듈이 전역 객체에 등록되었는지 확인
|
||||
const moduleClass = window[module.charAt(0).toUpperCase() + module.slice(1) + 'Module'];
|
||||
if (moduleClass) {
|
||||
this.modules.set(module, new moduleClass());
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 표시
|
||||
*/
|
||||
showDashboard() {
|
||||
document.getElementById('dashboard').classList.remove('hidden');
|
||||
document.getElementById('dynamicContent').classList.add('hidden');
|
||||
this.currentPage = 'dashboard';
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 콘텐츠 표시
|
||||
*/
|
||||
showDynamicContent(content) {
|
||||
document.getElementById('dashboard').classList.add('hidden');
|
||||
const dynamicContent = document.getElementById('dynamicContent');
|
||||
dynamicContent.innerHTML = content;
|
||||
dynamicContent.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 활성화 상태 업데이트
|
||||
*/
|
||||
updateActiveNavigation(hash) {
|
||||
// 모든 네비게이션 아이템에서 active 클래스 제거
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 현재 페이지에 해당하는 네비게이션 아이템에 active 클래스 추가
|
||||
// 구현 필요
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 제목 업데이트
|
||||
*/
|
||||
updatePageTitle(module, action) {
|
||||
const titles = {
|
||||
'dashboard': '대시보드',
|
||||
'issues': '부적합 사항',
|
||||
'projects': '프로젝트',
|
||||
'daily_work': '일일 공수',
|
||||
'reports': '보고서',
|
||||
'users': '사용자 관리'
|
||||
};
|
||||
|
||||
const title = titles[module] || module;
|
||||
document.getElementById('pageTitle').textContent = title;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 데이터 로드
|
||||
*/
|
||||
async loadDashboardData() {
|
||||
try {
|
||||
// 통계 데이터 로드 (임시 데이터)
|
||||
document.getElementById('totalIssues').textContent = '0';
|
||||
document.getElementById('activeProjects').textContent = '0';
|
||||
document.getElementById('monthlyHours').textContent = '0';
|
||||
document.getElementById('completionRate').textContent = '0%';
|
||||
|
||||
// 실제 API 호출로 대체 예정
|
||||
// const stats = await API.getDashboardStats();
|
||||
// this.updateDashboardStats(stats);
|
||||
} catch (error) {
|
||||
console.error('대시보드 데이터 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 등록
|
||||
*/
|
||||
registerEventListeners() {
|
||||
// 비밀번호 변경은 CommonHeader에서 처리
|
||||
|
||||
// 모바일 반응형
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
this.hideMobileOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 이동
|
||||
*/
|
||||
navigateTo(path) {
|
||||
window.location.hash = path.startsWith('#') ? path.substring(1) : path;
|
||||
|
||||
// 모바일에서 사이드바 닫기
|
||||
if (window.innerWidth < 768) {
|
||||
this.toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드바 토글
|
||||
*/
|
||||
toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const mainContent = document.getElementById('mainContent');
|
||||
const mobileOverlay = document.getElementById('mobileOverlay');
|
||||
|
||||
if (window.innerWidth < 768) {
|
||||
// 모바일
|
||||
if (sidebar.classList.contains('collapsed')) {
|
||||
sidebar.classList.remove('collapsed');
|
||||
mobileOverlay.classList.add('active');
|
||||
} else {
|
||||
sidebar.classList.add('collapsed');
|
||||
mobileOverlay.classList.remove('active');
|
||||
}
|
||||
} else {
|
||||
// 데스크톱
|
||||
if (this.sidebarCollapsed) {
|
||||
sidebar.classList.remove('collapsed');
|
||||
mainContent.classList.remove('expanded');
|
||||
this.sidebarCollapsed = false;
|
||||
} else {
|
||||
sidebar.classList.add('collapsed');
|
||||
mainContent.classList.add('expanded');
|
||||
this.sidebarCollapsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모바일 오버레이 숨기기
|
||||
*/
|
||||
hideMobileOverlay() {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
document.getElementById('mobileOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
// 비밀번호 변경 기능은 CommonHeader.js에서 처리됩니다.
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
logout() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('currentUser');
|
||||
this.redirectToLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
redirectToLogin() {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 표시
|
||||
*/
|
||||
showLoading() {
|
||||
document.getElementById('loadingOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 숨기기
|
||||
*/
|
||||
hideLoading() {
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 메시지 표시
|
||||
*/
|
||||
showSuccess(message) {
|
||||
this.showToast(message, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 메시지 표시
|
||||
*/
|
||||
showError(message) {
|
||||
this.showToast(message, 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
|
||||
type === 'success' ? 'bg-green-500' :
|
||||
type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
||||
}`;
|
||||
toast.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'} mr-2"></i>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수들 (HTML에서 호출)
|
||||
function toggleSidebar() {
|
||||
window.app.toggleSidebar();
|
||||
}
|
||||
|
||||
// 비밀번호 변경 기능은 CommonHeader.showPasswordModal()을 사용합니다.
|
||||
|
||||
function logout() {
|
||||
window.app.logout();
|
||||
}
|
||||
|
||||
// 앱 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.app = new App();
|
||||
});
|
||||
712
system3-nonconformance/web/static/js/components/common-header.js
Normal file
712
system3-nonconformance/web/static/js/components/common-header.js
Normal file
@@ -0,0 +1,712 @@
|
||||
/**
|
||||
* 공통 헤더 컴포넌트
|
||||
* 권한 기반으로 메뉴를 동적으로 생성하고 부드러운 페이지 전환을 제공
|
||||
*/
|
||||
|
||||
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: '/index.html#list',
|
||||
pageName: 'issues_manage',
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50 hover:bg-orange-100',
|
||||
subMenus: [
|
||||
{
|
||||
id: 'issues_inbox',
|
||||
title: '수신함',
|
||||
icon: 'fas fa-inbox',
|
||||
url: '/issues-inbox.html',
|
||||
pageName: 'issues_inbox',
|
||||
color: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
id: 'issues_management',
|
||||
title: '관리함',
|
||||
icon: 'fas fa-cog',
|
||||
url: '/issues-management.html',
|
||||
pageName: 'issues_management',
|
||||
color: 'text-green-600'
|
||||
},
|
||||
{
|
||||
id: 'issues_archive',
|
||||
title: '폐기함',
|
||||
icon: 'fas fa-archive',
|
||||
url: '/issues-archive.html',
|
||||
pageName: 'issues_archive',
|
||||
color: 'text-gray-600'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'issues_dashboard',
|
||||
title: '현황판',
|
||||
icon: 'fas fa-chart-line',
|
||||
url: '/issues-dashboard.html',
|
||||
pageName: 'issues_dashboard',
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50 hover:bg-purple-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',
|
||||
subMenus: [
|
||||
{
|
||||
id: 'reports_daily',
|
||||
title: '일일보고서',
|
||||
icon: 'fas fa-file-excel',
|
||||
url: '/reports-daily.html',
|
||||
pageName: 'reports_daily',
|
||||
color: 'text-green-600'
|
||||
},
|
||||
{
|
||||
id: 'reports_weekly',
|
||||
title: '주간보고서',
|
||||
icon: 'fas fa-calendar-week',
|
||||
url: '/reports-weekly.html',
|
||||
pageName: 'reports_weekly',
|
||||
color: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
id: 'reports_monthly',
|
||||
title: '월간보고서',
|
||||
icon: 'fas fa-calendar-alt',
|
||||
url: '/reports-monthly.html',
|
||||
pageName: 'reports_monthly',
|
||||
color: 'text-purple-600'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지 업데이트
|
||||
* @param {string} pageName - 새로운 페이지 이름
|
||||
*/
|
||||
updateCurrentPage(pageName) {
|
||||
this.currentPage = pageName;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 HTML 생성
|
||||
*/
|
||||
generateHeaderHTML() {
|
||||
const accessibleMenus = this.getAccessibleMenus();
|
||||
const userDisplayName = this.currentUser?.full_name || this.currentUser?.username || '사용자';
|
||||
const userRole = this.getUserRoleDisplay();
|
||||
|
||||
return `
|
||||
<header class="bg-white shadow-sm border-b sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 로고 및 제목 -->
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<i class="fas fa-clipboard-check text-2xl text-blue-600 mr-3"></i>
|
||||
<h1 class="text-xl font-bold text-gray-900">작업보고서</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 네비게이션 메뉴 -->
|
||||
<nav class="hidden md:flex space-x-2">
|
||||
${accessibleMenus.map(menu => this.generateMenuItemHTML(menu)).join('')}
|
||||
</nav>
|
||||
|
||||
<!-- 사용자 정보 및 메뉴 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 사용자 정보 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-medium text-gray-900">${userDisplayName}</div>
|
||||
<div class="text-xs text-gray-500">${userRole}</div>
|
||||
</div>
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<span class="text-white text-sm font-semibold">
|
||||
${userDisplayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 드롭다운 메뉴 -->
|
||||
<div class="relative">
|
||||
<button id="user-menu-button" class="p-2 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-md">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5">
|
||||
<a href="#" onclick="CommonHeader.showPasswordModal()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-key mr-2"></i>비밀번호 변경
|
||||
</a>
|
||||
<a href="#" onclick="CommonHeader.logout()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i>로그아웃
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모바일 메뉴 버튼 -->
|
||||
<button id="mobile-menu-button" class="md:hidden p-2 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-md">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모바일 메뉴 -->
|
||||
<div id="mobile-menu" class="md:hidden hidden border-t border-gray-200 py-3">
|
||||
<div class="space-y-1">
|
||||
${accessibleMenus.map(menu => this.generateMobileMenuItemHTML(menu)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 가능한 메뉴 필터링
|
||||
*/
|
||||
getAccessibleMenus() {
|
||||
return this.menuItems.filter(menu => {
|
||||
// admin은 모든 메뉴 접근 가능
|
||||
if (this.currentUser?.role === 'admin') {
|
||||
// 하위 메뉴가 있는 경우 하위 메뉴도 필터링
|
||||
if (menu.subMenus) {
|
||||
menu.accessibleSubMenus = menu.subMenus;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 권한 시스템이 로드되지 않았으면 기본 메뉴만
|
||||
if (!window.canAccessPage) {
|
||||
return ['issues_create', 'issues_view'].includes(menu.id);
|
||||
}
|
||||
|
||||
// 메인 메뉴 권한 체크
|
||||
const hasMainAccess = window.canAccessPage(menu.pageName);
|
||||
|
||||
// 하위 메뉴가 있는 경우 접근 가능한 하위 메뉴 필터링
|
||||
if (menu.subMenus) {
|
||||
menu.accessibleSubMenus = menu.subMenus.filter(subMenu =>
|
||||
window.canAccessPage(subMenu.pageName)
|
||||
);
|
||||
|
||||
// 메인 메뉴 접근 권한이 없어도 하위 메뉴 중 하나라도 접근 가능하면 표시
|
||||
return hasMainAccess || menu.accessibleSubMenus.length > 0;
|
||||
}
|
||||
|
||||
return hasMainAccess;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 데스크톱 메뉴 아이템 HTML 생성
|
||||
*/
|
||||
generateMenuItemHTML(menu) {
|
||||
const isActive = this.currentPage === menu.id;
|
||||
const activeClass = isActive ? 'bg-blue-100 text-blue-700' : `${menu.bgColor} ${menu.color}`;
|
||||
|
||||
// 하위 메뉴가 있는 경우 드롭다운 메뉴 생성
|
||||
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
|
||||
return `
|
||||
<div class="relative group">
|
||||
<button class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
|
||||
data-page="${menu.id}">
|
||||
<i class="${menu.icon} mr-2"></i>
|
||||
${menu.title}
|
||||
<i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||
</button>
|
||||
|
||||
<!-- 드롭다운 메뉴 -->
|
||||
<div class="absolute left-0 mt-1 w-48 bg-white rounded-md shadow-lg border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||
<div class="py-1">
|
||||
${menu.accessibleSubMenus.map(subMenu => `
|
||||
<a href="${subMenu.url}"
|
||||
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 ${this.currentPage === subMenu.id ? 'bg-blue-50 text-blue-700' : ''}"
|
||||
data-page="${subMenu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
|
||||
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
|
||||
${subMenu.title}
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 일반 메뉴 아이템
|
||||
return `
|
||||
<a href="${menu.url}"
|
||||
class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
|
||||
data-page="${menu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
|
||||
<i class="${menu.icon} mr-2"></i>
|
||||
${menu.title}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모바일 메뉴 아이템 HTML 생성
|
||||
*/
|
||||
generateMobileMenuItemHTML(menu) {
|
||||
const isActive = this.currentPage === menu.id;
|
||||
const activeClass = isActive ? 'bg-blue-50 text-blue-700 border-blue-500' : 'text-gray-700 hover:bg-gray-50';
|
||||
|
||||
// 하위 메뉴가 있는 경우
|
||||
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
|
||||
return `
|
||||
<div class="mobile-submenu-container">
|
||||
<button class="w-full flex items-center justify-between px-3 py-2 rounded-md text-base font-medium border-l-4 border-transparent ${activeClass}"
|
||||
onclick="this.nextElementSibling.classList.toggle('hidden')"
|
||||
data-page="${menu.id}">
|
||||
<div class="flex items-center">
|
||||
<i class="${menu.icon} mr-3"></i>
|
||||
${menu.title}
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-xs"></i>
|
||||
</button>
|
||||
|
||||
<!-- 하위 메뉴 -->
|
||||
<div class="hidden ml-6 mt-1 space-y-1">
|
||||
${menu.accessibleSubMenus.map(subMenu => `
|
||||
<a href="${subMenu.url}"
|
||||
class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 ${this.currentPage === subMenu.id ? 'bg-blue-50 text-blue-700' : ''}"
|
||||
data-page="${subMenu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
|
||||
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
|
||||
${subMenu.title}
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 일반 메뉴 아이템
|
||||
return `
|
||||
<a href="${menu.url}"
|
||||
class="nav-item block px-3 py-2 rounded-md text-base font-medium border-l-4 border-transparent ${activeClass}"
|
||||
data-page="${menu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
|
||||
<i class="${menu.icon} mr-3"></i>
|
||||
${menu.title}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 역할 표시명 가져오기
|
||||
*/
|
||||
getUserRoleDisplay() {
|
||||
const roleNames = {
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleNames[this.currentUser?.role] || '사용자';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 바인딩
|
||||
*/
|
||||
bindEvents() {
|
||||
// 사용자 메뉴 토글
|
||||
const userMenuButton = document.getElementById('user-menu-button');
|
||||
const userMenu = document.getElementById('user-menu');
|
||||
|
||||
if (userMenuButton && userMenu) {
|
||||
userMenuButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
userMenu.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// 외부 클릭 시 메뉴 닫기
|
||||
document.addEventListener('click', () => {
|
||||
userMenu.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// 모바일 메뉴 토글
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', () => {
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 네비게이션 (부드러운 전환)
|
||||
*/
|
||||
static navigateToPage(event, url, pageId) {
|
||||
event.preventDefault();
|
||||
|
||||
// 현재 페이지와 같으면 무시
|
||||
if (window.commonHeader?.currentPage === pageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
CommonHeader.showPageTransition();
|
||||
|
||||
// 페이지 이동
|
||||
setTimeout(() => {
|
||||
window.location.href = url;
|
||||
}, 150); // 부드러운 전환을 위한 딜레이
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 전환 로딩 표시
|
||||
*/
|
||||
static showPageTransition() {
|
||||
// 기존 로딩이 있으면 제거
|
||||
const existingLoader = document.getElementById('page-transition-loader');
|
||||
if (existingLoader) {
|
||||
existingLoader.remove();
|
||||
}
|
||||
|
||||
const loader = document.createElement('div');
|
||||
loader.id = 'page-transition-loader';
|
||||
loader.className = 'fixed inset-0 bg-white bg-opacity-75 flex items-center justify-center z-50';
|
||||
loader.innerHTML = `
|
||||
<div class="text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-600">페이지를 로드하는 중...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 모달 표시
|
||||
*/
|
||||
static showPasswordModal() {
|
||||
// 기존 모달이 있으면 제거
|
||||
const existingModal = document.getElementById('passwordChangeModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// 비밀번호 변경 모달 생성
|
||||
const modalHTML = `
|
||||
<div id="passwordChangeModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]">
|
||||
<div class="bg-white rounded-xl p-6 w-96 max-w-md mx-4 shadow-2xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">
|
||||
<i class="fas fa-key mr-2 text-blue-500"></i>비밀번호 변경
|
||||
</h3>
|
||||
<button onclick="CommonHeader.hidePasswordModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="passwordChangeForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
|
||||
<input type="password" id="currentPasswordInput"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required placeholder="현재 비밀번호를 입력하세요">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
|
||||
<input type="password" id="newPasswordInput"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required minlength="6" placeholder="새 비밀번호 (최소 6자)">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
|
||||
<input type="password" id="confirmPasswordInput"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required placeholder="새 비밀번호를 다시 입력하세요">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="button" onclick="CommonHeader.hidePasswordModal()"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-save mr-1"></i>변경
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
|
||||
// 폼 제출 이벤트 리스너 추가
|
||||
document.getElementById('passwordChangeForm').addEventListener('submit', CommonHeader.handlePasswordChange);
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
CommonHeader.hidePasswordModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 모달 숨기기
|
||||
*/
|
||||
static hidePasswordModal() {
|
||||
const modal = document.getElementById('passwordChangeModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 처리
|
||||
*/
|
||||
static async handlePasswordChange(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('currentPasswordInput').value;
|
||||
const newPassword = document.getElementById('newPasswordInput').value;
|
||||
const confirmPassword = document.getElementById('confirmPasswordInput').value;
|
||||
|
||||
// 새 비밀번호 확인
|
||||
if (newPassword !== confirmPassword) {
|
||||
CommonHeader.showToast('새 비밀번호가 일치하지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
CommonHeader.showToast('새 비밀번호는 최소 6자 이상이어야 합니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// AuthAPI가 있는지 확인
|
||||
if (typeof AuthAPI === 'undefined') {
|
||||
throw new Error('AuthAPI가 로드되지 않았습니다.');
|
||||
}
|
||||
|
||||
// API를 통한 비밀번호 변경
|
||||
await AuthAPI.changePassword(currentPassword, newPassword);
|
||||
|
||||
CommonHeader.showToast('비밀번호가 성공적으로 변경되었습니다.', 'success');
|
||||
CommonHeader.hidePasswordModal();
|
||||
|
||||
} catch (error) {
|
||||
console.error('비밀번호 변경 실패:', error);
|
||||
CommonHeader.showToast('현재 비밀번호가 올바르지 않거나 변경에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
static showToast(message, type = 'success') {
|
||||
// 기존 토스트 제거
|
||||
const existingToast = document.querySelector('.toast-message');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-message fixed bottom-4 right-4 px-4 py-3 rounded-lg text-white z-[10000] shadow-lg transform transition-all duration-300 ${
|
||||
type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`;
|
||||
|
||||
const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
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;
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 모바일 친화적 캘린더 컴포넌트
|
||||
* 터치 및 스와이프 지원, 날짜 범위 선택 기능
|
||||
*/
|
||||
|
||||
class MobileCalendar {
|
||||
constructor(containerId, options = {}) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.options = {
|
||||
locale: 'ko-KR',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
maxRange: 90, // 최대 90일 범위
|
||||
onDateSelect: null,
|
||||
onRangeSelect: null,
|
||||
...options
|
||||
};
|
||||
|
||||
this.currentDate = new Date();
|
||||
this.selectedStartDate = null;
|
||||
this.selectedEndDate = null;
|
||||
this.isSelecting = false;
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
render() {
|
||||
const calendarHTML = `
|
||||
<div class="mobile-calendar">
|
||||
<!-- 빠른 선택 버튼들 -->
|
||||
<div class="quick-select-buttons mb-4">
|
||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||
<button class="quick-btn" data-range="today">오늘</button>
|
||||
<button class="quick-btn" data-range="week">이번 주</button>
|
||||
<button class="quick-btn" data-range="month">이번 달</button>
|
||||
<button class="quick-btn" data-range="last7">최근 7일</button>
|
||||
<button class="quick-btn" data-range="last30">최근 30일</button>
|
||||
<button class="quick-btn" data-range="all">전체</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 헤더 -->
|
||||
<div class="calendar-header flex items-center justify-between mb-4">
|
||||
<button class="nav-btn" id="prevMonth">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<h3 class="month-year text-lg font-semibold" id="monthYear"></h3>
|
||||
<button class="nav-btn" id="nextMonth">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 요일 헤더 -->
|
||||
<div class="weekdays grid grid-cols-7 gap-1 mb-2">
|
||||
<div class="weekday">일</div>
|
||||
<div class="weekday">월</div>
|
||||
<div class="weekday">화</div>
|
||||
<div class="weekday">수</div>
|
||||
<div class="weekday">목</div>
|
||||
<div class="weekday">금</div>
|
||||
<div class="weekday">토</div>
|
||||
</div>
|
||||
|
||||
<!-- 날짜 그리드 -->
|
||||
<div class="calendar-grid grid grid-cols-7 gap-1" id="calendarGrid">
|
||||
<!-- 날짜들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 선택된 범위 표시 -->
|
||||
<div class="selected-range mt-4 p-3 bg-blue-50 rounded-lg" id="selectedRange" style="display: none;">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-blue-700" id="rangeText"></span>
|
||||
<button class="clear-btn text-blue-600 hover:text-blue-800" id="clearRange">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용법 안내 -->
|
||||
<div class="usage-hint mt-3 text-xs text-gray-500 text-center">
|
||||
📅 날짜를 터치하여 시작일을 선택하고, 다시 터치하여 종료일을 선택하세요
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = calendarHTML;
|
||||
this.updateCalendar();
|
||||
}
|
||||
|
||||
updateCalendar() {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
|
||||
// 월/년 표시 업데이트
|
||||
document.getElementById('monthYear').textContent =
|
||||
`${year}년 ${month + 1}월`;
|
||||
|
||||
// 캘린더 그리드 생성
|
||||
this.generateCalendarGrid(year, month);
|
||||
}
|
||||
|
||||
generateCalendarGrid(year, month) {
|
||||
const grid = document.getElementById('calendarGrid');
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
let html = '';
|
||||
const today = new Date();
|
||||
|
||||
// 6주 표시 (42일)
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(startDate.getDate() + i);
|
||||
|
||||
const isCurrentMonth = date.getMonth() === month;
|
||||
const isToday = this.isSameDate(date, today);
|
||||
const isSelected = this.isDateInRange(date);
|
||||
const isStart = this.selectedStartDate && this.isSameDate(date, this.selectedStartDate);
|
||||
const isEnd = this.selectedEndDate && this.isSameDate(date, this.selectedEndDate);
|
||||
|
||||
let classes = ['calendar-day'];
|
||||
if (!isCurrentMonth) classes.push('other-month');
|
||||
if (isToday) classes.push('today');
|
||||
if (isSelected) classes.push('selected');
|
||||
if (isStart) classes.push('range-start');
|
||||
if (isEnd) classes.push('range-end');
|
||||
|
||||
html += `
|
||||
<div class="${classes.join(' ')}"
|
||||
data-date="${date.toISOString().split('T')[0]}"
|
||||
data-timestamp="${date.getTime()}">
|
||||
${date.getDate()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 빠른 선택 버튼들
|
||||
this.container.querySelectorAll('.quick-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const range = e.target.dataset.range;
|
||||
this.selectQuickRange(range);
|
||||
});
|
||||
});
|
||||
|
||||
// 월 네비게이션
|
||||
document.getElementById('prevMonth').addEventListener('click', () => {
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||
this.updateCalendar();
|
||||
});
|
||||
|
||||
document.getElementById('nextMonth').addEventListener('click', () => {
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||
this.updateCalendar();
|
||||
});
|
||||
|
||||
// 날짜 선택
|
||||
this.container.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('calendar-day')) {
|
||||
this.handleDateClick(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
// 터치 이벤트 (스와이프 지원)
|
||||
this.container.addEventListener('touchstart', (e) => {
|
||||
this.touchStartX = e.touches[0].clientX;
|
||||
this.touchStartY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
this.container.addEventListener('touchend', (e) => {
|
||||
if (!this.touchStartX || !this.touchStartY) return;
|
||||
|
||||
const touchEndX = e.changedTouches[0].clientX;
|
||||
const touchEndY = e.changedTouches[0].clientY;
|
||||
|
||||
const diffX = this.touchStartX - touchEndX;
|
||||
const diffY = this.touchStartY - touchEndY;
|
||||
|
||||
// 수평 스와이프가 수직 스와이프보다 클 때만 처리
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
||||
if (diffX > 0) {
|
||||
// 왼쪽으로 스와이프 - 다음 달
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||
} else {
|
||||
// 오른쪽으로 스와이프 - 이전 달
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||
}
|
||||
this.updateCalendar();
|
||||
}
|
||||
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
});
|
||||
|
||||
// 범위 지우기
|
||||
document.getElementById('clearRange').addEventListener('click', () => {
|
||||
this.clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
handleDateClick(dayElement) {
|
||||
const dateStr = dayElement.dataset.date;
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
|
||||
if (!this.selectedStartDate || (this.selectedStartDate && this.selectedEndDate)) {
|
||||
// 새로운 선택 시작
|
||||
this.selectedStartDate = date;
|
||||
this.selectedEndDate = null;
|
||||
this.isSelecting = true;
|
||||
} else if (this.selectedStartDate && !this.selectedEndDate) {
|
||||
// 종료일 선택
|
||||
if (date < this.selectedStartDate) {
|
||||
// 시작일보다 이전 날짜를 선택하면 시작일로 설정
|
||||
this.selectedEndDate = this.selectedStartDate;
|
||||
this.selectedStartDate = date;
|
||||
} else {
|
||||
this.selectedEndDate = date;
|
||||
}
|
||||
this.isSelecting = false;
|
||||
|
||||
// 범위가 너무 크면 제한
|
||||
const daysDiff = Math.abs(this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24);
|
||||
if (daysDiff > this.options.maxRange) {
|
||||
alert(`최대 ${this.options.maxRange}일까지만 선택할 수 있습니다.`);
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCalendar();
|
||||
this.updateSelectedRange();
|
||||
|
||||
// 콜백 호출
|
||||
if (this.selectedStartDate && this.selectedEndDate && this.options.onRangeSelect) {
|
||||
this.options.onRangeSelect(this.selectedStartDate, this.selectedEndDate);
|
||||
}
|
||||
}
|
||||
|
||||
selectQuickRange(range) {
|
||||
const today = new Date();
|
||||
let startDate, endDate;
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
startDate = endDate = new Date(today);
|
||||
break;
|
||||
case 'week':
|
||||
startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - today.getDay());
|
||||
endDate = new Date(startDate);
|
||||
endDate.setDate(startDate.getDate() + 6);
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
break;
|
||||
case 'last7':
|
||||
endDate = new Date(today);
|
||||
startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - 6);
|
||||
break;
|
||||
case 'last30':
|
||||
endDate = new Date(today);
|
||||
startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - 29);
|
||||
break;
|
||||
case 'all':
|
||||
this.clearSelection();
|
||||
if (this.options.onRangeSelect) {
|
||||
this.options.onRangeSelect(null, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedStartDate = startDate;
|
||||
this.selectedEndDate = endDate;
|
||||
this.updateCalendar();
|
||||
this.updateSelectedRange();
|
||||
|
||||
if (this.options.onRangeSelect) {
|
||||
this.options.onRangeSelect(startDate, endDate);
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedRange() {
|
||||
const rangeElement = document.getElementById('selectedRange');
|
||||
const rangeText = document.getElementById('rangeText');
|
||||
|
||||
if (this.selectedStartDate && this.selectedEndDate) {
|
||||
const startStr = this.formatDate(this.selectedStartDate);
|
||||
const endStr = this.formatDate(this.selectedEndDate);
|
||||
const daysDiff = Math.ceil((this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
rangeText.textContent = `${startStr} ~ ${endStr} (${daysDiff}일)`;
|
||||
rangeElement.style.display = 'block';
|
||||
} else if (this.selectedStartDate) {
|
||||
rangeText.textContent = `시작일: ${this.formatDate(this.selectedStartDate)} (종료일을 선택하세요)`;
|
||||
rangeElement.style.display = 'block';
|
||||
} else {
|
||||
rangeElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selectedStartDate = null;
|
||||
this.selectedEndDate = null;
|
||||
this.isSelecting = false;
|
||||
this.updateCalendar();
|
||||
this.updateSelectedRange();
|
||||
}
|
||||
|
||||
isDateInRange(date) {
|
||||
if (!this.selectedStartDate) return false;
|
||||
if (!this.selectedEndDate) return this.isSameDate(date, this.selectedStartDate);
|
||||
|
||||
return date >= this.selectedStartDate && date <= this.selectedEndDate;
|
||||
}
|
||||
|
||||
isSameDate(date1, date2) {
|
||||
return date1.toDateString() === date2.toDateString();
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// 외부에서 호출할 수 있는 메서드들
|
||||
getSelectedRange() {
|
||||
return {
|
||||
startDate: this.selectedStartDate,
|
||||
endDate: this.selectedEndDate
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedRange(startDate, endDate) {
|
||||
this.selectedStartDate = startDate;
|
||||
this.selectedEndDate = endDate;
|
||||
this.updateCalendar();
|
||||
this.updateSelectedRange();
|
||||
}
|
||||
}
|
||||
|
||||
// 전역으로 노출
|
||||
window.MobileCalendar = MobileCalendar;
|
||||
272
system3-nonconformance/web/static/js/core/auth-manager.js
Normal file
272
system3-nonconformance/web/static/js/core/auth-manager.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 중앙화된 인증 관리자
|
||||
* 페이지 간 이동 시 불필요한 API 호출을 방지하고 인증 상태를 효율적으로 관리
|
||||
*/
|
||||
class AuthManager {
|
||||
constructor() {
|
||||
this.currentUser = null;
|
||||
this.isAuthenticated = false;
|
||||
this.lastAuthCheck = null;
|
||||
this.authCheckInterval = 5 * 60 * 1000; // 5분마다 토큰 유효성 체크
|
||||
this.listeners = new Set();
|
||||
|
||||
// 초기화
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
init() {
|
||||
console.log('🔐 AuthManager 초기화');
|
||||
|
||||
// localStorage에서 사용자 정보 복원
|
||||
this.restoreUserFromStorage();
|
||||
|
||||
// 토큰 만료 체크 타이머 설정
|
||||
this.setupTokenExpiryCheck();
|
||||
|
||||
// 페이지 가시성 변경 시 토큰 체크
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && this.shouldCheckAuth()) {
|
||||
this.refreshAuth();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage에서 사용자 정보 복원
|
||||
*/
|
||||
restoreUserFromStorage() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const userStr = localStorage.getItem('currentUser');
|
||||
|
||||
console.log('🔍 localStorage 확인:');
|
||||
console.log('- 토큰 존재:', !!token);
|
||||
console.log('- 사용자 정보 존재:', !!userStr);
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
this.currentUser = JSON.parse(userStr);
|
||||
this.isAuthenticated = true;
|
||||
this.lastAuthCheck = Date.now();
|
||||
console.log('✅ 저장된 사용자 정보 복원:', this.currentUser.username);
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 정보 복원 실패:', error);
|
||||
this.clearAuth();
|
||||
}
|
||||
} else {
|
||||
console.log('❌ 토큰 또는 사용자 정보 없음 - 로그인 필요');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증이 필요한지 확인
|
||||
*/
|
||||
shouldCheckAuth() {
|
||||
if (!this.isAuthenticated) return true;
|
||||
if (!this.lastAuthCheck) return true;
|
||||
|
||||
const timeSinceLastCheck = Date.now() - this.lastAuthCheck;
|
||||
return timeSinceLastCheck > this.authCheckInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 상태 확인 (필요시에만 API 호출)
|
||||
*/
|
||||
async checkAuth() {
|
||||
console.log('🔍 AuthManager.checkAuth() 호출됨');
|
||||
console.log('- 현재 인증 상태:', this.isAuthenticated);
|
||||
console.log('- 현재 사용자:', this.currentUser?.username || 'null');
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
console.log('❌ 토큰 없음 - 인증 실패');
|
||||
this.clearAuth();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 최근에 체크했으면 캐시된 정보 사용
|
||||
if (this.isAuthenticated && !this.shouldCheckAuth()) {
|
||||
console.log('✅ 캐시된 인증 정보 사용:', this.currentUser.username);
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
// API 호출이 필요한 경우
|
||||
console.log('🔄 API 호출 필요 - refreshAuth 실행');
|
||||
return await this.refreshAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* 강제로 인증 정보 새로고침 (API 호출)
|
||||
*/
|
||||
async refreshAuth() {
|
||||
console.log('🔄 인증 정보 새로고침 (API 호출)');
|
||||
|
||||
try {
|
||||
// API가 로드될 때까지 대기
|
||||
await this.waitForAPI();
|
||||
|
||||
const user = await AuthAPI.getCurrentUser();
|
||||
|
||||
this.currentUser = user;
|
||||
this.isAuthenticated = true;
|
||||
this.lastAuthCheck = Date.now();
|
||||
|
||||
// localStorage 업데이트
|
||||
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||
|
||||
console.log('✅ 인증 정보 새로고침 완료:', user.username);
|
||||
|
||||
// 리스너들에게 알림
|
||||
this.notifyListeners('auth-success', user);
|
||||
|
||||
return user;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 인증 실패:', error);
|
||||
this.clearAuth();
|
||||
this.notifyListeners('auth-failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 로드 대기
|
||||
*/
|
||||
async waitForAPI() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50;
|
||||
|
||||
while (typeof AuthAPI === 'undefined' && attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (typeof AuthAPI === 'undefined') {
|
||||
throw new Error('AuthAPI를 로드할 수 없습니다');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 정보 클리어
|
||||
*/
|
||||
clearAuth() {
|
||||
console.log('🧹 인증 정보 클리어');
|
||||
|
||||
this.currentUser = null;
|
||||
this.isAuthenticated = false;
|
||||
this.lastAuthCheck = null;
|
||||
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('currentUser');
|
||||
|
||||
this.notifyListeners('auth-cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 처리
|
||||
*/
|
||||
async login(username, password) {
|
||||
console.log('🔑 로그인 시도:', username);
|
||||
|
||||
try {
|
||||
await this.waitForAPI();
|
||||
const data = await AuthAPI.login(username, password);
|
||||
|
||||
this.currentUser = data.user;
|
||||
this.isAuthenticated = true;
|
||||
this.lastAuthCheck = Date.now();
|
||||
|
||||
// localStorage 저장
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('currentUser', JSON.stringify(data.user));
|
||||
|
||||
console.log('✅ 로그인 성공:', data.user.username);
|
||||
|
||||
this.notifyListeners('login-success', data.user);
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 로그인 실패:', error);
|
||||
this.clearAuth();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 처리
|
||||
*/
|
||||
logout() {
|
||||
console.log('🚪 로그아웃');
|
||||
|
||||
this.clearAuth();
|
||||
this.notifyListeners('logout');
|
||||
|
||||
// 로그인 페이지로 이동
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료 체크 타이머 설정
|
||||
*/
|
||||
setupTokenExpiryCheck() {
|
||||
// 30분마다 토큰 유효성 체크
|
||||
setInterval(() => {
|
||||
if (this.isAuthenticated) {
|
||||
console.log('⏰ 정기 토큰 유효성 체크');
|
||||
this.refreshAuth().catch(() => {
|
||||
console.log('🔄 토큰 만료 - 로그아웃 처리');
|
||||
this.logout();
|
||||
});
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 등록
|
||||
*/
|
||||
addEventListener(callback) {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 제거
|
||||
*/
|
||||
removeEventListener(callback) {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스너들에게 알림
|
||||
*/
|
||||
notifyListeners(event, data = null) {
|
||||
this.listeners.forEach(callback => {
|
||||
try {
|
||||
callback(event, data);
|
||||
} catch (error) {
|
||||
console.error('리스너 콜백 오류:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 반환
|
||||
*/
|
||||
getCurrentUser() {
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 상태 반환
|
||||
*/
|
||||
isLoggedIn() {
|
||||
return this.isAuthenticated && !!this.currentUser;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.authManager = new AuthManager();
|
||||
|
||||
console.log('🎯 AuthManager 로드 완료');
|
||||
621
system3-nonconformance/web/static/js/core/keyboard-shortcuts.js
Normal file
621
system3-nonconformance/web/static/js/core/keyboard-shortcuts.js
Normal file
@@ -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 = `
|
||||
<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();
|
||||
368
system3-nonconformance/web/static/js/core/page-manager.js
Normal file
368
system3-nonconformance/web/static/js/core/page-manager.js
Normal file
@@ -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 = `
|
||||
<div class="text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
||||
<p class="text-lg font-medium text-gray-700">페이지를 로드하는 중...</p>
|
||||
<p class="text-sm text-gray-500 mt-1">잠시만 기다려주세요</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 로더 숨기기
|
||||
*/
|
||||
hidePageLoader() {
|
||||
const loader = document.getElementById('page-loader');
|
||||
if (loader) {
|
||||
loader.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 페이지 표시
|
||||
*/
|
||||
showErrorPage(error) {
|
||||
this.hidePageLoader();
|
||||
|
||||
const errorContainer = document.createElement('div');
|
||||
errorContainer.className = 'fixed inset-0 bg-gray-50 flex items-center justify-center z-50';
|
||||
errorContainer.innerHTML = `
|
||||
<div class="text-center max-w-md mx-auto p-8">
|
||||
<div class="mb-6">
|
||||
<i class="fas fa-exclamation-triangle text-6xl text-red-500"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">페이지 로드 실패</h2>
|
||||
<p class="text-gray-600 mb-6">${error.message || '알 수 없는 오류가 발생했습니다.'}</p>
|
||||
<div class="space-x-4">
|
||||
<button onclick="window.location.reload()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
다시 시도
|
||||
</button>
|
||||
<button onclick="window.location.href='/index.html'"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
|
||||
홈으로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(errorContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 정리
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.currentPage && this.loadedModules.has(this.currentPage)) {
|
||||
const module = this.loadedModules.get(this.currentPage);
|
||||
if (module.cleanup) {
|
||||
module.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 페이지 모듈 클래스
|
||||
* 모든 페이지 모듈이 상속받아야 하는 기본 클래스
|
||||
*/
|
||||
class BasePageModule {
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
this.initialized = false;
|
||||
this.eventListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 초기화 (하위 클래스에서 구현)
|
||||
*/
|
||||
async initialize() {
|
||||
throw new Error('initialize 메서드를 구현해야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 재초기화
|
||||
*/
|
||||
async reinitialize(options = {}) {
|
||||
this.cleanup();
|
||||
this.options = { ...this.options, ...options };
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 등록 (자동 정리를 위해)
|
||||
*/
|
||||
addEventListener(element, event, handler) {
|
||||
element.addEventListener(event, handler);
|
||||
this.eventListeners.push({ element, event, handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 정리
|
||||
*/
|
||||
cleanup() {
|
||||
// 등록된 이벤트 리스너 제거
|
||||
this.eventListeners.forEach(({ element, event, handler }) => {
|
||||
element.removeEventListener(event, handler);
|
||||
});
|
||||
this.eventListeners = [];
|
||||
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 표시
|
||||
*/
|
||||
showLoading(container, message = '로딩 중...') {
|
||||
if (typeof container === 'string') {
|
||||
container = document.getElementById(container);
|
||||
}
|
||||
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-3"></div>
|
||||
<p class="text-gray-600">${message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 표시
|
||||
*/
|
||||
showError(container, message = '오류가 발생했습니다.') {
|
||||
if (typeof container === 'string') {
|
||||
container = document.getElementById(container);
|
||||
}
|
||||
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-500 mb-3"></i>
|
||||
<p class="text-gray-600">${message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스
|
||||
window.pageManager = new PageManager();
|
||||
window.BasePageModule = BasePageModule;
|
||||
317
system3-nonconformance/web/static/js/core/page-preloader.js
Normal file
317
system3-nonconformance/web/static/js/core/page-preloader.js
Normal file
@@ -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: '/index.html#list', 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();
|
||||
267
system3-nonconformance/web/static/js/core/permissions.js
Normal file
267
system3-nonconformance/web/static/js/core/permissions.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 단순화된 페이지 권한 관리 시스템
|
||||
* 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: true },
|
||||
'issues_inbox': { title: '수신함', defaultAccess: true },
|
||||
'issues_management': { title: '관리함', defaultAccess: false },
|
||||
'issues_archive': { title: '폐기함', defaultAccess: false },
|
||||
'issues_dashboard': { title: '현황판', defaultAccess: true },
|
||||
'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 apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const response = await fetch(`${apiUrl}/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 apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const response = await fetch(`${apiUrl}/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 apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const response = await fetch(`${apiUrl}/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);
|
||||
139
system3-nonconformance/web/static/js/date-utils.js
Normal file
139
system3-nonconformance/web/static/js/date-utils.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 날짜 관련 유틸리티 함수들
|
||||
* 한국 표준시(KST) 기준으로 처리
|
||||
*/
|
||||
|
||||
const DateUtils = {
|
||||
/**
|
||||
* UTC 시간을 KST로 변환
|
||||
* @param {string|Date} dateInput - UTC 날짜 문자열 또는 Date 객체
|
||||
* @returns {Date} KST 시간대의 Date 객체
|
||||
*/
|
||||
toKST(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
// UTC 시간에 9시간 추가 (KST = UTC+9)
|
||||
return new Date(date.getTime() + (date.getTimezoneOffset() * 60000) + (9 * 3600000));
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 KST 시간 가져오기
|
||||
* @returns {Date} 현재 KST 시간
|
||||
*/
|
||||
nowKST() {
|
||||
const now = new Date();
|
||||
return this.toKST(now);
|
||||
},
|
||||
|
||||
/**
|
||||
* KST 날짜를 한국식 문자열로 포맷
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @param {boolean} includeTime - 시간 포함 여부
|
||||
* @returns {string} 포맷된 날짜 문자열
|
||||
*/
|
||||
formatKST(dateInput, includeTime = false) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'Asia/Seoul'
|
||||
};
|
||||
|
||||
if (includeTime) {
|
||||
options.hour = '2-digit';
|
||||
options.minute = '2-digit';
|
||||
options.hour12 = false;
|
||||
}
|
||||
|
||||
return date.toLocaleString('ko-KR', options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 상대적 시간 표시 (예: 3분 전, 2시간 전)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {string} 상대적 시간 문자열
|
||||
*/
|
||||
getRelativeTime(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return '방금 전';
|
||||
if (diffMin < 60) return `${diffMin}분 전`;
|
||||
if (diffHour < 24) return `${diffHour}시간 전`;
|
||||
if (diffDay < 7) return `${diffDay}일 전`;
|
||||
|
||||
return this.formatKST(date);
|
||||
},
|
||||
|
||||
/**
|
||||
* 오늘 날짜인지 확인 (KST 기준)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isToday(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const today = new Date();
|
||||
|
||||
return date.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' }) ===
|
||||
today.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
},
|
||||
|
||||
/**
|
||||
* 이번 주인지 확인 (KST 기준, 월요일 시작)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isThisWeek(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const now = new Date();
|
||||
|
||||
// 주의 시작일 (월요일) 계산
|
||||
const startOfWeek = new Date(now);
|
||||
const day = startOfWeek.getDay();
|
||||
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
|
||||
startOfWeek.setDate(diff);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
// 주의 끝일 (일요일) 계산
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
return date >= startOfWeek && date <= endOfWeek;
|
||||
},
|
||||
|
||||
/**
|
||||
* ISO 문자열을 로컬 date input 값으로 변환
|
||||
* @param {string} isoString - ISO 날짜 문자열
|
||||
* @returns {string} YYYY-MM-DD 형식
|
||||
*/
|
||||
toDateInputValue(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 날짜 차이 계산 (일 단위)
|
||||
* @param {string|Date} date1 - 첫 번째 날짜
|
||||
* @param {string|Date} date2 - 두 번째 날짜
|
||||
* @returns {number} 일 수 차이
|
||||
*/
|
||||
getDaysDiff(date1, date2) {
|
||||
const d1 = typeof date1 === 'string' ? new Date(date1) : date1;
|
||||
const d2 = typeof date2 === 'string' ? new Date(date2) : date2;
|
||||
const diffMs = Math.abs(d2 - d1);
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
};
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.DateUtils = DateUtils;
|
||||
134
system3-nonconformance/web/static/js/image-utils.js
Normal file
134
system3-nonconformance/web/static/js/image-utils.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 이미지 압축 및 최적화 유틸리티
|
||||
*/
|
||||
|
||||
const ImageUtils = {
|
||||
/**
|
||||
* 이미지를 압축하고 리사이즈
|
||||
* @param {File|Blob|String} source - 이미지 파일, Blob 또는 base64 문자열
|
||||
* @param {Object} options - 압축 옵션
|
||||
* @returns {Promise<String>} - 압축된 base64 이미지
|
||||
*/
|
||||
async compressImage(source, options = {}) {
|
||||
const {
|
||||
maxWidth = 1024, // 최대 너비
|
||||
maxHeight = 1024, // 최대 높이
|
||||
quality = 0.7, // JPEG 품질 (0-1)
|
||||
format = 'jpeg' // 출력 형식
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let img = new Image();
|
||||
|
||||
// 이미지 로드 완료 시
|
||||
img.onload = () => {
|
||||
// Canvas 생성
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 리사이즈 계산
|
||||
let { width, height } = this.calculateDimensions(
|
||||
img.width,
|
||||
img.height,
|
||||
maxWidth,
|
||||
maxHeight
|
||||
);
|
||||
|
||||
// Canvas 크기 설정
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// 압축된 이미지를 base64로 변환
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('이미지 압축 실패'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
}, `image/${format}`, quality);
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error('이미지 로드 실패'));
|
||||
|
||||
// 소스 타입에 따라 처리
|
||||
if (typeof source === 'string') {
|
||||
// Base64 문자열인 경우
|
||||
img.src = source;
|
||||
} else if (source instanceof File || source instanceof Blob) {
|
||||
// File 또는 Blob인 경우
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
img.src = reader.result;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(source);
|
||||
} else {
|
||||
reject(new Error('지원하지 않는 이미지 형식'));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 크기 계산 (비율 유지)
|
||||
*/
|
||||
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
|
||||
// 원본 크기가 제한 내에 있으면 그대로 반환
|
||||
if (originalWidth <= maxWidth && originalHeight <= maxHeight) {
|
||||
return { width: originalWidth, height: originalHeight };
|
||||
}
|
||||
|
||||
// 비율 계산
|
||||
const widthRatio = maxWidth / originalWidth;
|
||||
const heightRatio = maxHeight / originalHeight;
|
||||
const ratio = Math.min(widthRatio, heightRatio);
|
||||
|
||||
return {
|
||||
width: Math.round(originalWidth * ratio),
|
||||
height: Math.round(originalHeight * ratio)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 파일 크기를 사람이 읽을 수 있는 형식으로 변환
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
/**
|
||||
* Base64 문자열의 크기 계산
|
||||
*/
|
||||
getBase64Size(base64String) {
|
||||
const base64Length = base64String.length - (base64String.indexOf(',') + 1);
|
||||
const padding = (base64String.charAt(base64String.length - 2) === '=') ? 2 :
|
||||
((base64String.charAt(base64String.length - 1) === '=') ? 1 : 0);
|
||||
return (base64Length * 0.75) - padding;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 미리보기 생성 (썸네일)
|
||||
*/
|
||||
async createThumbnail(source, size = 150) {
|
||||
return this.compressImage(source, {
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
quality: 0.8
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.ImageUtils = ImageUtils;
|
||||
Reference in New Issue
Block a user