- 알림 시스템 구축 (navbar 알림 아이콘, 드롭다운) - 알림 수신자 설정 기능 (계정관리 페이지) - 시설설비 관리 페이지 추가 (수리 워크플로우) - 수리 신청 → 접수 → 처리중 → 완료 상태 관리 - 사이드바 메뉴 구조 개선 (공장 관리 카테고리) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
583 lines
20 KiB
JavaScript
583 lines
20 KiB
JavaScript
// /js/app-init.js
|
|
// 앱 초기화 - 인증, 네비바, 사이드바를 한 번에 로드
|
|
// 모든 페이지에서 이 하나의 스크립트만 로드하면 됨
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ===== 캐시 설정 =====
|
|
const CACHE_DURATION = 10 * 60 * 1000; // 10분
|
|
const COMPONENT_CACHE_PREFIX = 'component_';
|
|
|
|
// ===== 인증 함수 =====
|
|
function isLoggedIn() {
|
|
const token = localStorage.getItem('token');
|
|
return token && token !== 'undefined' && token !== 'null';
|
|
}
|
|
|
|
function getUser() {
|
|
const user = localStorage.getItem('user');
|
|
return user ? JSON.parse(user) : null;
|
|
}
|
|
|
|
function clearAuthData() {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
localStorage.removeItem('userPageAccess');
|
|
}
|
|
|
|
// ===== 페이지 권한 캐시 =====
|
|
let pageAccessPromise = null;
|
|
|
|
async function getPageAccess(currentUser) {
|
|
if (!currentUser || !currentUser.user_id) return null;
|
|
|
|
// 캐시 확인
|
|
const cached = localStorage.getItem('userPageAccess');
|
|
if (cached) {
|
|
try {
|
|
const cacheData = JSON.parse(cached);
|
|
if (Date.now() - cacheData.timestamp < CACHE_DURATION) {
|
|
return cacheData.pages;
|
|
}
|
|
} catch (e) {
|
|
localStorage.removeItem('userPageAccess');
|
|
}
|
|
}
|
|
|
|
// 이미 로딩 중이면 기존 Promise 반환
|
|
if (pageAccessPromise) return pageAccessPromise;
|
|
|
|
// 새로운 API 호출
|
|
pageAccessPromise = (async () => {
|
|
try {
|
|
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) return null;
|
|
|
|
const data = await response.json();
|
|
const pages = data.data.pageAccess || [];
|
|
|
|
localStorage.setItem('userPageAccess', JSON.stringify({
|
|
pages: pages,
|
|
timestamp: Date.now()
|
|
}));
|
|
|
|
return pages;
|
|
} catch (error) {
|
|
console.error('페이지 권한 조회 오류:', error);
|
|
return null;
|
|
} finally {
|
|
pageAccessPromise = null;
|
|
}
|
|
})();
|
|
|
|
return pageAccessPromise;
|
|
}
|
|
|
|
async function getAccessiblePageKeys(currentUser) {
|
|
const pages = await getPageAccess(currentUser);
|
|
if (!pages) return [];
|
|
return pages.filter(p => p.can_access === 1).map(p => p.page_key);
|
|
}
|
|
|
|
// ===== 현재 페이지 키 추출 =====
|
|
function getCurrentPageKey() {
|
|
const path = window.location.pathname;
|
|
if (!path.startsWith('/pages/')) return null;
|
|
const pagePath = path.substring(7).replace('.html', '');
|
|
return pagePath.replace(/\//g, '.');
|
|
}
|
|
|
|
// ===== 컴포넌트 로더 =====
|
|
async function loadComponent(name, selector, processor) {
|
|
const container = document.querySelector(selector);
|
|
if (!container) return;
|
|
|
|
const paths = {
|
|
'navbar': '/components/navbar.html',
|
|
'sidebar-nav': '/components/sidebar-nav.html'
|
|
};
|
|
|
|
const componentPath = paths[name];
|
|
if (!componentPath) return;
|
|
|
|
try {
|
|
const cacheKey = COMPONENT_CACHE_PREFIX + name;
|
|
let html = sessionStorage.getItem(cacheKey);
|
|
|
|
if (!html) {
|
|
const response = await fetch(componentPath);
|
|
if (!response.ok) throw new Error('컴포넌트 로드 실패');
|
|
html = await response.text();
|
|
try { sessionStorage.setItem(cacheKey, html); } catch (e) {}
|
|
}
|
|
|
|
if (processor) {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, 'text/html');
|
|
await processor(doc);
|
|
container.innerHTML = doc.body.innerHTML;
|
|
} else {
|
|
container.innerHTML = html;
|
|
}
|
|
} catch (error) {
|
|
console.error(`컴포넌트 로드 오류 (${name}):`, error);
|
|
}
|
|
}
|
|
|
|
// ===== 네비바 처리 =====
|
|
const ROLE_NAMES = {
|
|
'system admin': '시스템 관리자',
|
|
'admin': '관리자',
|
|
'leader': '그룹장',
|
|
'user': '작업자',
|
|
'support': '지원팀',
|
|
'default': '사용자'
|
|
};
|
|
|
|
async function processNavbar(doc, currentUser, accessiblePageKeys) {
|
|
const userRole = (currentUser.role || '').toLowerCase();
|
|
const isAdmin = userRole === 'admin' || userRole === 'system admin';
|
|
|
|
if (isAdmin) {
|
|
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
|
|
} else {
|
|
doc.querySelectorAll('[data-page-key]').forEach(item => {
|
|
const pageKey = item.getAttribute('data-page-key');
|
|
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) return;
|
|
if (!accessiblePageKeys.includes(pageKey)) item.remove();
|
|
});
|
|
doc.querySelectorAll('.admin-only').forEach(el => el.remove());
|
|
}
|
|
|
|
// 사용자 정보 표시
|
|
const displayName = currentUser.name || currentUser.username;
|
|
const roleName = ROLE_NAMES[userRole] || ROLE_NAMES.default;
|
|
|
|
const setElementText = (id, text) => {
|
|
const el = doc.getElementById(id);
|
|
if (el) el.textContent = text;
|
|
};
|
|
|
|
setElementText('userName', displayName);
|
|
setElementText('userRole', roleName);
|
|
setElementText('userInitial', displayName.charAt(0));
|
|
}
|
|
|
|
// ===== 사이드바 처리 =====
|
|
async function processSidebar(doc, currentUser, accessiblePageKeys) {
|
|
const userRole = (currentUser.role || '').toLowerCase();
|
|
const accessLevel = (currentUser.access_level || '').toLowerCase();
|
|
// role 또는 access_level로 관리자 확인
|
|
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
|
|
accessLevel === 'admin' || accessLevel === 'system';
|
|
|
|
if (isAdmin) {
|
|
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
|
|
} else {
|
|
doc.querySelectorAll('[data-page-key]').forEach(item => {
|
|
const pageKey = item.getAttribute('data-page-key');
|
|
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) return;
|
|
if (!accessiblePageKeys.includes(pageKey)) item.style.display = 'none';
|
|
});
|
|
doc.querySelectorAll('.nav-category.admin-only').forEach(el => el.remove());
|
|
}
|
|
|
|
// 현재 페이지 하이라이트
|
|
const currentPath = window.location.pathname;
|
|
doc.querySelectorAll('.nav-item').forEach(item => {
|
|
const href = item.getAttribute('href');
|
|
if (href && currentPath.includes(href.replace(/^\//, ''))) {
|
|
item.classList.add('active');
|
|
const category = item.closest('.nav-category');
|
|
if (category) category.classList.add('expanded');
|
|
}
|
|
});
|
|
|
|
// 저장된 상태 복원 (기본값: 접힌 상태)
|
|
const isCollapsed = localStorage.getItem('sidebarCollapsed') !== 'false';
|
|
const sidebar = doc.querySelector('.sidebar-nav');
|
|
if (isCollapsed && sidebar) {
|
|
sidebar.classList.add('collapsed');
|
|
document.body.classList.add('sidebar-collapsed');
|
|
}
|
|
|
|
const expandedCategories = JSON.parse(localStorage.getItem('sidebarExpanded') || '[]');
|
|
expandedCategories.forEach(category => {
|
|
const el = doc.querySelector(`[data-category="${category}"]`);
|
|
if (el) el.classList.add('expanded');
|
|
});
|
|
}
|
|
|
|
// ===== 사이드바 이벤트 설정 =====
|
|
function setupSidebarEvents() {
|
|
const sidebar = document.getElementById('sidebarNav');
|
|
const toggle = document.getElementById('sidebarToggle');
|
|
if (!sidebar || !toggle) return;
|
|
|
|
toggle.addEventListener('click', () => {
|
|
sidebar.classList.toggle('collapsed');
|
|
document.body.classList.toggle('sidebar-collapsed');
|
|
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
|
|
});
|
|
|
|
sidebar.querySelectorAll('.nav-category-header').forEach(header => {
|
|
header.addEventListener('click', () => {
|
|
const category = header.closest('.nav-category');
|
|
category.classList.toggle('expanded');
|
|
|
|
const expanded = [];
|
|
sidebar.querySelectorAll('.nav-category.expanded').forEach(cat => {
|
|
const name = cat.getAttribute('data-category');
|
|
if (name) expanded.push(name);
|
|
});
|
|
localStorage.setItem('sidebarExpanded', JSON.stringify(expanded));
|
|
});
|
|
});
|
|
}
|
|
|
|
// ===== 네비바 이벤트 설정 =====
|
|
function setupNavbarEvents() {
|
|
const logoutButton = document.getElementById('logoutBtn');
|
|
if (logoutButton) {
|
|
logoutButton.addEventListener('click', () => {
|
|
if (confirm('로그아웃 하시겠습니까?')) {
|
|
clearAuthData();
|
|
window.location.href = '/index.html';
|
|
}
|
|
});
|
|
}
|
|
|
|
// 알림 버튼 이벤트
|
|
const notificationBtn = document.getElementById('notificationBtn');
|
|
const notificationDropdown = document.getElementById('notificationDropdown');
|
|
const notificationWrapper = document.getElementById('notificationWrapper');
|
|
|
|
if (notificationBtn && notificationDropdown) {
|
|
notificationBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
notificationDropdown.classList.toggle('show');
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (notificationWrapper && !notificationWrapper.contains(e.target)) {
|
|
notificationDropdown.classList.remove('show');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ===== 알림 로드 =====
|
|
async function loadNotifications() {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) return;
|
|
|
|
const response = await fetch(`${window.API_BASE_URL}/notifications/unread`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (!response.ok) return;
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
const notifications = result.data || [];
|
|
updateNotificationBadge(notifications.length);
|
|
renderNotificationList(notifications);
|
|
}
|
|
} catch (error) {
|
|
console.warn('알림 로드 오류:', error.message);
|
|
}
|
|
}
|
|
|
|
function updateNotificationBadge(count) {
|
|
const badge = document.getElementById('notificationBadge');
|
|
const btn = document.getElementById('notificationBtn');
|
|
if (!badge) return;
|
|
|
|
if (count > 0) {
|
|
badge.textContent = count > 99 ? '99+' : count;
|
|
badge.style.display = 'flex';
|
|
btn?.classList.add('has-notifications');
|
|
} else {
|
|
badge.style.display = 'none';
|
|
btn?.classList.remove('has-notifications');
|
|
}
|
|
}
|
|
|
|
function renderNotificationList(notifications) {
|
|
const list = document.getElementById('notificationList');
|
|
if (!list) return;
|
|
|
|
if (notifications.length === 0) {
|
|
list.innerHTML = '<div class="notification-empty">새 알림이 없습니다.</div>';
|
|
return;
|
|
}
|
|
|
|
const icons = { repair: '🔧', safety: '⚠️', system: '📢', equipment: '🔩', maintenance: '🛠️' };
|
|
|
|
list.innerHTML = notifications.slice(0, 5).map(n => `
|
|
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}" data-url="${n.link_url || ''}">
|
|
<div class="notification-item-icon ${n.type || 'repair'}">${icons[n.type] || '🔔'}</div>
|
|
<div class="notification-item-content">
|
|
<div class="notification-item-title">${escapeHtml(n.title)}</div>
|
|
<div class="notification-item-desc">${escapeHtml(n.message || '')}</div>
|
|
</div>
|
|
<div class="notification-item-time">${formatTimeAgo(n.created_at)}</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
list.querySelectorAll('.notification-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const url = item.dataset.url;
|
|
// 수리 알림은 클릭해도 읽음 처리 안함 (수리 처리 페이지에서 확인 처리해야 함)
|
|
window.location.href = url || '/pages/admin/notifications.html';
|
|
});
|
|
});
|
|
}
|
|
|
|
function formatTimeAgo(dateString) {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return '방금 전';
|
|
if (diffMins < 60) return `${diffMins}분 전`;
|
|
if (diffHours < 24) return `${diffHours}시간 전`;
|
|
if (diffDays < 7) return `${diffDays}일 전`;
|
|
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ===== 날짜/시간 업데이트 =====
|
|
function updateDateTime() {
|
|
const now = new Date();
|
|
const timeEl = document.getElementById('timeValue');
|
|
if (timeEl) timeEl.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
|
|
|
const dateEl = document.getElementById('dateValue');
|
|
if (dateEl) {
|
|
const days = ['일', '월', '화', '수', '목', '토'];
|
|
dateEl.textContent = `${now.getMonth() + 1}월 ${now.getDate()}일 (${days[now.getDay()]})`;
|
|
}
|
|
}
|
|
|
|
// ===== 날씨 업데이트 =====
|
|
const WEATHER_ICONS = { clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥', cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷', cloudy: '⛅', overcast: '☁️' };
|
|
const WEATHER_NAMES = { clear: '맑음', rain: '비', snow: '눈', heat: '폭염', cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지', cloudy: '구름많음', overcast: '흐림' };
|
|
|
|
async function updateWeather() {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) return;
|
|
|
|
// 캐시 확인
|
|
const cached = sessionStorage.getItem('weatherCache');
|
|
let result;
|
|
if (cached) {
|
|
const cacheData = JSON.parse(cached);
|
|
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
|
|
result = cacheData.data;
|
|
}
|
|
}
|
|
|
|
if (!result) {
|
|
const response = await fetch(`${window.API_BASE_URL}/tbm/weather/current`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (!response.ok) return;
|
|
result = await response.json();
|
|
sessionStorage.setItem('weatherCache', JSON.stringify({ data: result, timestamp: Date.now() }));
|
|
}
|
|
|
|
if (result.success && result.data) {
|
|
const { temperature, conditions } = result.data;
|
|
const tempEl = document.getElementById('weatherTemp');
|
|
if (tempEl && temperature != null) tempEl.textContent = `${Math.round(temperature)}°C`;
|
|
|
|
const iconEl = document.getElementById('weatherIcon');
|
|
const descEl = document.getElementById('weatherDesc');
|
|
if (conditions && conditions.length > 0) {
|
|
const primary = conditions[0];
|
|
if (iconEl) iconEl.textContent = WEATHER_ICONS[primary] || '🌤️';
|
|
if (descEl) descEl.textContent = WEATHER_NAMES[primary] || '맑음';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('날씨 정보 로드 실패');
|
|
}
|
|
}
|
|
|
|
// ===== 메인 초기화 =====
|
|
async function init() {
|
|
console.log('🚀 app-init 시작');
|
|
|
|
// 1. 인증 확인
|
|
if (!isLoggedIn()) {
|
|
clearAuthData();
|
|
window.location.href = '/index.html';
|
|
return;
|
|
}
|
|
|
|
const currentUser = getUser();
|
|
if (!currentUser || !currentUser.username) {
|
|
clearAuthData();
|
|
window.location.href = '/index.html';
|
|
return;
|
|
}
|
|
|
|
console.log('✅ 인증 확인:', currentUser.username);
|
|
|
|
const userRole = (currentUser.role || '').toLowerCase();
|
|
const accessLevel = (currentUser.access_level || '').toLowerCase();
|
|
// role 또는 access_level로 관리자 확인
|
|
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
|
|
accessLevel === 'admin' || accessLevel === 'system';
|
|
|
|
// 2. 페이지 접근 권한 체크 (Admin은 건너뛰기)
|
|
let accessiblePageKeys = [];
|
|
if (!isAdmin) {
|
|
const pageKey = getCurrentPageKey();
|
|
if (pageKey && pageKey !== 'dashboard' && !pageKey.startsWith('profile.')) {
|
|
accessiblePageKeys = await getAccessiblePageKeys(currentUser);
|
|
if (!accessiblePageKeys.includes(pageKey)) {
|
|
alert('이 페이지에 접근할 권한이 없습니다.');
|
|
window.location.href = '/pages/dashboard.html';
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. 사이드바 컨테이너 생성 (없으면)
|
|
let sidebarContainer = document.getElementById('sidebar-container');
|
|
if (!sidebarContainer) {
|
|
sidebarContainer = document.createElement('div');
|
|
sidebarContainer.id = 'sidebar-container';
|
|
document.body.prepend(sidebarContainer);
|
|
console.log('📦 사이드바 컨테이너 생성됨');
|
|
}
|
|
|
|
// 4. 네비바와 사이드바 동시 로드
|
|
console.log('📥 컴포넌트 로딩 시작');
|
|
await Promise.all([
|
|
loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)),
|
|
loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys))
|
|
]);
|
|
console.log('✅ 컴포넌트 로딩 완료');
|
|
|
|
// 5. 이벤트 설정
|
|
setupNavbarEvents();
|
|
setupSidebarEvents();
|
|
document.body.classList.add('has-sidebar');
|
|
|
|
// 6. 페이지 전환 로딩 인디케이터 설정
|
|
setupPageTransitionLoader();
|
|
|
|
// 7. 날짜/시간 (비동기)
|
|
updateDateTime();
|
|
setInterval(updateDateTime, 1000);
|
|
|
|
// 8. 날씨 (백그라운드)
|
|
setTimeout(updateWeather, 100);
|
|
|
|
// 9. 알림 로드 (30초마다 갱신)
|
|
setTimeout(loadNotifications, 200);
|
|
setInterval(loadNotifications, 30000);
|
|
|
|
console.log('✅ app-init 완료');
|
|
}
|
|
|
|
// ===== 페이지 전환 로딩 인디케이터 =====
|
|
function setupPageTransitionLoader() {
|
|
// 로딩 바 스타일 추가
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
#page-loader {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 0;
|
|
height: 3px;
|
|
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
|
z-index: 99999;
|
|
transition: width 0.3s ease;
|
|
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
|
|
}
|
|
#page-loader.loading {
|
|
width: 70%;
|
|
}
|
|
#page-loader.done {
|
|
width: 100%;
|
|
opacity: 0;
|
|
transition: width 0.2s ease, opacity 0.3s ease 0.2s;
|
|
}
|
|
body.page-loading {
|
|
cursor: wait;
|
|
}
|
|
body.page-loading * {
|
|
pointer-events: none;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
// 로딩 바 엘리먼트 생성
|
|
const loader = document.createElement('div');
|
|
loader.id = 'page-loader';
|
|
document.body.appendChild(loader);
|
|
|
|
// 모든 내부 링크에 클릭 이벤트 추가
|
|
document.addEventListener('click', (e) => {
|
|
const link = e.target.closest('a');
|
|
if (!link) return;
|
|
|
|
const href = link.getAttribute('href');
|
|
if (!href) return;
|
|
|
|
// 외부 링크, 해시 링크, javascript: 링크 제외
|
|
if (href.startsWith('http') || href.startsWith('#') || href.startsWith('javascript:')) return;
|
|
|
|
// 새 탭 링크 제외
|
|
if (link.target === '_blank') return;
|
|
|
|
// 로딩 시작
|
|
loader.classList.remove('done');
|
|
loader.classList.add('loading');
|
|
document.body.classList.add('page-loading');
|
|
});
|
|
|
|
// 페이지 떠날 때 완료 표시
|
|
window.addEventListener('beforeunload', () => {
|
|
const loader = document.getElementById('page-loader');
|
|
if (loader) {
|
|
loader.classList.remove('loading');
|
|
loader.classList.add('done');
|
|
}
|
|
});
|
|
}
|
|
|
|
// DOMContentLoaded 시 실행
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
// 전역 노출 (필요시)
|
|
window.appInit = { getUser, clearAuthData, isLoggedIn };
|
|
})();
|