- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
476 lines
14 KiB
JavaScript
476 lines
14 KiB
JavaScript
// /js/load-navbar.js
|
|
import { getUser, clearAuthData } from './auth.js';
|
|
import { loadComponent } from './component-loader.js';
|
|
import { config } from './config.js';
|
|
|
|
// 역할 이름을 한글로 변환하는 맵
|
|
const ROLE_NAMES = {
|
|
'system admin': '시스템 관리자',
|
|
'admin': '관리자',
|
|
'system': '시스템 관리자',
|
|
'leader': '그룹장',
|
|
'user': '작업자',
|
|
'support': '지원팀',
|
|
'default': '사용자',
|
|
};
|
|
|
|
/**
|
|
* 네비게이션 바 DOM을 사용자 정보와 역할에 맞게 수정하는 프로세서입니다.
|
|
* @param {Document} doc - 파싱된 HTML 문서 객체
|
|
*/
|
|
async function processNavbarDom(doc) {
|
|
const currentUser = getUser();
|
|
if (!currentUser) return;
|
|
|
|
// 1. 역할 및 페이지 권한 기반 메뉴 필터링
|
|
await filterMenuByPageAccess(doc, currentUser);
|
|
|
|
// 2. 사용자 정보 채우기
|
|
populateUserInfo(doc, currentUser);
|
|
}
|
|
|
|
/**
|
|
* 사용자의 페이지 접근 권한에 따라 메뉴 항목을 필터링합니다.
|
|
* @param {Document} doc - 파싱된 HTML 문서 객체
|
|
* @param {object} currentUser - 현재 사용자 객체
|
|
*/
|
|
async function filterMenuByPageAccess(doc, currentUser) {
|
|
const userRole = (currentUser.role || '').toLowerCase();
|
|
|
|
// Admin은 모든 메뉴 표시 + .admin-only 요소 활성화
|
|
if (userRole === 'admin' || userRole === 'system admin') {
|
|
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 사용자의 페이지 접근 권한 조회
|
|
const cached = localStorage.getItem('userPageAccess');
|
|
let accessiblePages = null;
|
|
|
|
if (cached) {
|
|
const cacheData = JSON.parse(cached);
|
|
// 캐시가 5분 이내인 경우 사용
|
|
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
|
|
accessiblePages = cacheData.pages;
|
|
}
|
|
}
|
|
|
|
// 캐시가 없으면 API 호출
|
|
if (!accessiblePages) {
|
|
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) {
|
|
console.error('페이지 권한 조회 실패:', response.status);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
accessiblePages = data.data.pageAccess || [];
|
|
|
|
// 캐시 저장
|
|
localStorage.setItem('userPageAccess', JSON.stringify({
|
|
pages: accessiblePages,
|
|
timestamp: Date.now()
|
|
}));
|
|
}
|
|
|
|
// 접근 가능한 페이지 키 목록
|
|
const accessiblePageKeys = accessiblePages
|
|
.filter(p => p.can_access === 1)
|
|
.map(p => p.page_key);
|
|
|
|
// 메뉴 항목에 data-page-key 속성이 있으면 해당 권한 체크
|
|
const menuItems = doc.querySelectorAll('[data-page-key]');
|
|
menuItems.forEach(item => {
|
|
const pageKey = item.getAttribute('data-page-key');
|
|
|
|
// 대시보드와 프로필 페이지는 모든 사용자 접근 가능
|
|
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) {
|
|
return;
|
|
}
|
|
|
|
// 권한이 없으면 메뉴 항목 제거
|
|
if (!accessiblePageKeys.includes(pageKey)) {
|
|
item.remove();
|
|
}
|
|
});
|
|
|
|
// Admin 전용 메뉴는 무조건 제거
|
|
doc.querySelectorAll('.admin-only').forEach(el => el.remove());
|
|
|
|
} catch (error) {
|
|
console.error('메뉴 필터링 오류:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 네비게이션 바에 사용자 정보를 채웁니다.
|
|
* @param {Document} doc - 파싱된 HTML 문서 객체
|
|
* @param {object} user - 현재 사용자 객체
|
|
*/
|
|
function populateUserInfo(doc, user) {
|
|
const displayName = user.name || user.username;
|
|
// 대소문자 구분 없이 처리
|
|
const roleLower = (user.role || '').toLowerCase();
|
|
const roleName = ROLE_NAMES[roleLower] || ROLE_NAMES.default;
|
|
|
|
const elements = {
|
|
'userName': displayName,
|
|
'userRole': roleName,
|
|
'userInitial': displayName.charAt(0),
|
|
};
|
|
|
|
for (const id in elements) {
|
|
const el = doc.getElementById(id);
|
|
if (el) el.textContent = elements[id];
|
|
}
|
|
|
|
// 메인 대시보드 URL 설정
|
|
const dashboardBtn = doc.getElementById('dashboardBtn');
|
|
if (dashboardBtn) {
|
|
dashboardBtn.href = '/pages/dashboard.html';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 네비게이션 바와 관련된 모든 이벤트를 설정합니다.
|
|
*/
|
|
function setupNavbarEvents() {
|
|
const logoutButton = document.getElementById('logoutBtn');
|
|
if (logoutButton) {
|
|
logoutButton.addEventListener('click', () => {
|
|
if (confirm('로그아웃 하시겠습니까?')) {
|
|
clearAuthData();
|
|
window.location.href = config.paths.loginPage;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 모바일 메뉴 버튼
|
|
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
|
|
const sidebar = document.getElementById('sidebarNav');
|
|
const overlay = document.getElementById('sidebarOverlay');
|
|
|
|
if (mobileMenuBtn && sidebar) {
|
|
mobileMenuBtn.addEventListener('click', () => {
|
|
sidebar.classList.toggle('mobile-open');
|
|
overlay?.classList.toggle('show');
|
|
document.body.classList.toggle('sidebar-mobile-open');
|
|
});
|
|
}
|
|
|
|
// 오버레이 클릭시 닫기
|
|
if (overlay) {
|
|
overlay.addEventListener('click', () => {
|
|
sidebar?.classList.remove('mobile-open');
|
|
overlay.classList.remove('show');
|
|
document.body.classList.remove('sidebar-mobile-open');
|
|
});
|
|
}
|
|
|
|
// 사이드바 토글 버튼 (모바일에서 닫기)
|
|
const sidebarToggle = document.getElementById('sidebarToggle');
|
|
if (sidebarToggle && sidebar) {
|
|
sidebarToggle.addEventListener('click', () => {
|
|
if (window.innerWidth <= 1024) {
|
|
sidebar.classList.remove('mobile-open');
|
|
overlay?.classList.remove('show');
|
|
document.body.classList.remove('sidebar-mobile-open');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 날짜와 시간을 업데이트하는 함수
|
|
*/
|
|
function updateDateTime() {
|
|
const now = new Date();
|
|
|
|
// 시간 업데이트 (시 분 초 형식으로 고정)
|
|
const timeElement = document.getElementById('timeValue');
|
|
if (timeElement) {
|
|
const hours = String(now.getHours()).padStart(2, '0');
|
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
timeElement.textContent = `${hours}시 ${minutes}분 ${seconds}초`;
|
|
}
|
|
|
|
// 날짜 업데이트
|
|
const dateElement = document.getElementById('dateValue');
|
|
if (dateElement) {
|
|
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
|
const month = now.getMonth() + 1;
|
|
const date = now.getDate();
|
|
const day = days[now.getDay()];
|
|
dateElement.textContent = `${month}월 ${date}일 (${day})`;
|
|
}
|
|
}
|
|
|
|
// 날씨 아이콘 매핑
|
|
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 response = await fetch(`${window.API_BASE_URL}/tbm/weather/current`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('날씨 API 호출 실패');
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
const { temperature, conditions, weatherData } = result.data;
|
|
|
|
// 온도 표시
|
|
const tempElement = document.getElementById('weatherTemp');
|
|
if (tempElement && temperature !== null && temperature !== undefined) {
|
|
tempElement.textContent = `${Math.round(temperature)}°C`;
|
|
}
|
|
|
|
// 날씨 아이콘 및 설명
|
|
const iconElement = document.getElementById('weatherIcon');
|
|
const descElement = document.getElementById('weatherDesc');
|
|
|
|
if (conditions && conditions.length > 0) {
|
|
const primaryCondition = conditions[0];
|
|
if (iconElement) {
|
|
iconElement.textContent = WEATHER_ICONS[primaryCondition] || '🌤️';
|
|
}
|
|
if (descElement) {
|
|
descElement.textContent = WEATHER_NAMES[primaryCondition] || '맑음';
|
|
}
|
|
} else {
|
|
if (iconElement) iconElement.textContent = '☀️';
|
|
if (descElement) descElement.textContent = '맑음';
|
|
}
|
|
|
|
// 날씨 섹션 표시
|
|
const weatherSection = document.getElementById('weatherSection');
|
|
if (weatherSection) {
|
|
weatherSection.style.opacity = '1';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('날씨 정보 로드 실패:', error.message);
|
|
// 실패해도 기본값 표시
|
|
const descElement = document.getElementById('weatherDesc');
|
|
if (descElement) {
|
|
descElement.textContent = '날씨 정보 없음';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// 알림 시스템
|
|
// ==========================================
|
|
|
|
/**
|
|
* 알림 관련 이벤트 설정
|
|
*/
|
|
function setupNotificationEvents() {
|
|
const notificationBtn = document.getElementById('notificationBtn');
|
|
const notificationDropdown = document.getElementById('notificationDropdown');
|
|
const notificationWrapper = document.getElementById('notificationWrapper');
|
|
|
|
if (notificationBtn) {
|
|
notificationBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
notificationDropdown?.classList.toggle('show');
|
|
});
|
|
}
|
|
|
|
// 외부 클릭시 드롭다운 닫기
|
|
document.addEventListener('click', (e) => {
|
|
if (notificationWrapper && notificationDropdown && !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`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'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 NOTIF_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'}">
|
|
${NOTIF_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 linkUrl = item.dataset.url;
|
|
// 수리 알림은 클릭해도 읽음 처리 안함 (수리 처리 페이지에서 확인 처리)
|
|
window.location.href = linkUrl || '/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' });
|
|
}
|
|
|
|
/**
|
|
* HTML 이스케이프
|
|
*/
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// 메인 로직: DOMContentLoaded 시 실행
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
if (getUser()) {
|
|
// 1. 컴포넌트 로드 및 DOM 수정
|
|
await loadComponent('navbar', '#navbar-container', processNavbarDom);
|
|
|
|
// 2. DOM에 삽입된 후에 이벤트 리스너 설정
|
|
setupNavbarEvents();
|
|
|
|
// 3. 실시간 날짜/시간 업데이트 시작
|
|
updateDateTime();
|
|
setInterval(updateDateTime, 1000);
|
|
|
|
// 4. 날씨 정보 로드 (10분마다 갱신)
|
|
updateWeather();
|
|
setInterval(updateWeather, 10 * 60 * 1000);
|
|
|
|
// 5. 알림 이벤트 설정 및 로드 (30초마다 갱신)
|
|
setupNotificationEvents();
|
|
loadNotifications();
|
|
setInterval(loadNotifications, 30000);
|
|
}
|
|
}); |