feat: 안전 코드 tksafety 이관 + 사용자 관리 정리 + UI Tailwind 전환

Phase 1: tksafety에 출입신청/체크리스트 API·웹 추가, tkfb 안전 코드 삭제
Phase 2: 사용자 관리 페이지 삭제, API 축소, 알림 수신자 tkuser 이관
Phase 3: tkuser 권한 페이지 정의 업데이트
Phase 4: 전체 34개 페이지 Tailwind CSS + tkfb-core.js 전환,
         미사용 CSS 20개·인프라 JS 10개·템플릿·컴포넌트 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-13 10:46:22 +09:00
parent 8373fe9e75
commit 9fda89a374
133 changed files with 5255 additions and 26181 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
// ✅ /js/admin.js (수정됨 - 중복 로딩 제거)
async function initDashboard() {
// 로그인 토큰 확인
const token = localStorage.getItem('sso_token') || (window.getSSOToken ? window.getSSOToken() : null);
if (!token) {
location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
return;
}
// ✅ navbar, sidebar는 각각의 모듈에서 처리하도록 변경
// load-navbar.js, load-sidebar.js가 자동으로 처리함
// ✅ 콘텐츠만 직접 로딩 (admin-sections.html이 자동 로딩됨)
console.log('관리자 대시보드 초기화 완료');
}
// ✅ 보조 함수 - 필요시 수동 컴포넌트 로딩용
async function loadComponent(id, url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
const element = document.getElementById(id);
if (element) {
element.innerHTML = html;
} else {
console.warn(`요소를 찾을 수 없습니다: ${id}`);
}
} catch (err) {
console.error(`컴포넌트 로딩 실패 (${url}):`, err);
}
}
document.addEventListener('DOMContentLoaded', initDashboard);

View File

@@ -1,139 +0,0 @@
// /public/js/api-helper.js
// ES6 모듈 의존성 제거 - 브라우저 호환성 개선
// API 설정 (window 객체에서 가져오기)
const API_BASE_URL = window.API_BASE_URL || 'http://localhost:30005/api';
// 인증 관련 함수들 (직접 구현)
function getToken() {
// SSO 토큰 우선, 기존 token 폴백
if (window.getSSOToken) return window.getSSOToken();
const token = localStorage.getItem('sso_token');
return token && token !== 'undefined' && token !== 'null' ? token : null;
}
function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
}
/**
* 로그인 API를 호출합니다. (인증이 필요 없는 public 요청)
* @param {string} username - 사용자 아이디
* @param {string} password - 사용자 비밀번호
* @returns {Promise<object>} - API 응답 결과
*/
async function login(username, password) {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const result = await response.json();
if (!response.ok) {
// API 에러 응답을 그대로 에러 객체로 던져서 호출부에서 처리하도록 함
throw new Error(result.error || '로그인에 실패했습니다.');
}
return result;
}
/**
* 인증이 필요한 API 요청을 위한 fetch 래퍼 함수
* @param {string} endpoint - /로 시작하는 API 엔드포인트
* @param {object} options - fetch 함수에 전달할 옵션
* @returns {Promise<Response>} - fetch 응답 객체
*/
async function authFetch(endpoint, options = {}) {
const token = getToken();
if (!token) {
console.error('토큰이 없습니다. 로그인이 필요합니다.');
clearAuthData(); // 인증 정보 정리
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent(window.location.href);
// 에러를 던져서 후속 실행을 중단
throw new Error('인증 토큰이 없습니다.');
}
const defaultHeaders = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
...defaultHeaders,
...options.headers
}
});
// 401 Unauthorized 에러 발생 시, 토큰이 유효하지 않다는 의미
if (response.status === 401) {
console.error('인증 실패. 토큰이 만료되었거나 유효하지 않습니다.');
clearAuthData(); // 만료된 인증 정보 정리
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent(window.location.href);
throw new Error('인증에 실패했습니다.');
}
return response;
}
// 공통 API 요청 함수들
/**
* GET 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
*/
async function apiGet(endpoint) {
const response = await authFetch(endpoint);
return response.json();
}
/**
* POST 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
* @param {object} data - 전송할 데이터
*/
async function apiPost(endpoint, data) {
const response = await authFetch(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
return response.json();
}
/**
* PUT 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
* @param {object} data - 전송할 데이터
*/
async function apiPut(endpoint, data) {
const response = await authFetch(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
return response.json();
}
/**
* DELETE 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
*/
async function apiDelete(endpoint) {
const response = await authFetch(endpoint, {
method: 'DELETE'
});
return response.json();
}
// 전역 함수로 설정
window.login = login;
window.apiGet = apiGet;
window.apiPost = apiPost;
window.apiPut = apiPut;
window.apiDelete = apiDelete;
window.getToken = getToken;
window.clearAuthData = clearAuthData;

View File

@@ -1,589 +0,0 @@
// /js/app-init.js
// 앱 초기화 - 인증, 네비바, 사이드바를 한 번에 로드
// 모든 페이지에서 이 하나의 스크립트만 로드하면 됨
// api-base.js가 먼저 로드되어야 함 (getSSOToken, getSSOUser, clearSSOAuth 등)
(function() {
'use strict';
// ===== 캐시 설정 =====
const CACHE_DURATION = 10 * 60 * 1000; // 10분
const COMPONENT_CACHE_PREFIX = 'component_v3_';
// ===== 인증 함수 (api-base.js의 SSO 함수 활용) =====
function isLoggedIn() {
const token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
return token && token !== 'undefined' && token !== 'null';
}
function getUser() {
if (window.getSSOUser) return window.getSSOUser();
const user = localStorage.getItem('sso_user');
try { return user ? JSON.parse(user) : null; } catch(e) { return null; }
}
function getToken() {
return window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
}
function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_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 token = getToken();
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${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);
}
// ===== 현재 페이지 키 추출 =====
// 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유)
const PAGE_KEY_ALIASES = {
'work.tbm-mobile': 'work.tbm',
'work.tbm-create': 'work.tbm',
'work.report-create-mobile': 'work.report-create',
'admin.equipment-detail': 'admin.equipments',
'safety.issue-detail': 'safety.issue-report'
};
function getCurrentPageKey() {
const path = window.location.pathname;
if (!path.startsWith('/pages/')) return null;
const pagePath = path.substring(7).replace('.html', '');
const rawKey = pagePath.replace(/\//g, '.');
return PAGE_KEY_ALIASES[rawKey] || rawKey;
}
// ===== 컴포넌트 로더 =====
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();
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();
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent('/pages/dashboard.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 = getToken();
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: '\ud83d\udd27', safety: '\u26a0\ufe0f', system: '\ud83d\udce2', equipment: '\ud83d\udea9', maintenance: '\ud83d\udee0\ufe0f' };
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] || '\ud83d\udd14'}</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) {
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
timeEl.textContent = `${hours}${minutes}${seconds}`;
}
const dateEl = document.getElementById('dateValue');
if (dateEl) {
const days = ['일', '월', '화', '수', '목', '토'];
dateEl.textContent = `${now.getMonth() + 1}${now.getDate()}일 (${days[now.getDay()]})`;
}
}
// ===== 날씨 업데이트 =====
const WEATHER_ICONS = { clear: '\u2600\ufe0f', rain: '\ud83c\udf27\ufe0f', snow: '\u2744\ufe0f', heat: '\ud83e\udd75', cold: '\ud83e\udd76', wind: '\ud83c\udf2c\ufe0f', fog: '\ud83c\udf2b\ufe0f', dust: '\ud83d\ude37', cloudy: '\u26c5', overcast: '\u2601\ufe0f' };
const WEATHER_NAMES = { clear: '맑음', rain: '비', snow: '눈', heat: '폭염', cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지', cloudy: '구름많음', overcast: '흐림' };
async function updateWeather() {
try {
const token = getToken();
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] || '\ud83c\udf24\ufe0f';
if (descEl) descEl.textContent = WEATHER_NAMES[primary] || '맑음';
}
}
} catch (error) {
console.warn('날씨 정보 로드 실패');
}
}
// ===== 메인 초기화 =====
async function init() {
// 1. 인증 확인
if (!isLoggedIn()) {
clearAuthData();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent(window.location.href);
return;
}
const currentUser = getUser();
if (!currentUser || !currentUser.username) {
clearAuthData();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent(window.location.href);
return;
}
const userRole = (currentUser.role || '').toLowerCase();
const accessLevel = (currentUser.access_level || '').toLowerCase();
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
accessLevel === 'admin' || accessLevel === 'system';
// 2. 페이지 접근 권한 체크 (Admin은 건너뛰기, API 실패시 허용)
let accessiblePageKeys = [];
const pageKey = getCurrentPageKey();
if (!isAdmin) {
const pages = await getPageAccess(currentUser);
if (pages) {
accessiblePageKeys = pages.filter(p => p.can_access == 1).map(p => p.page_key);
if (pageKey && pageKey !== 'dashboard' && !pageKey.startsWith('profile.')) {
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);
}
// 4. 네비바와 사이드바 동시 로드
await Promise.all([
loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)),
loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys))
]);
// 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);
}
// ===== 페이지 전환 로딩 인디케이터 =====
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;
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, getToken, clearAuthData, isLoggedIn };
})();

View File

@@ -1,178 +0,0 @@
// /js/auth-check.js
// auth.js의 함수들을 직접 구현 (모듈 의존성 제거)
function isLoggedIn() {
var token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
return token && token !== 'undefined' && token !== 'null';
}
function getUser() {
return window.getSSOUser ? window.getSSOUser() : (function() {
var u = localStorage.getItem('sso_user');
return u ? JSON.parse(u) : null;
})();
}
function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
localStorage.removeItem('userPageAccess');
}
/**
* 현재 페이지의 page_key를 URL 경로로부터 추출
* 예: /pages/work/tbm.html -> work.tbm
* /pages/admin/accounts.html -> admin.accounts
* /pages/dashboard.html -> dashboard
*/
// 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유)
var PAGE_KEY_ALIASES = {
'work.tbm-create': 'work.tbm',
'work.tbm-mobile': 'work.tbm'
};
function getCurrentPageKey() {
const path = window.location.pathname;
// /pages/로 시작하는지 확인
if (!path.startsWith('/pages/')) {
return null;
}
// /pages/ 이후 경로 추출
const pagePath = path.substring(7); // '/pages/' 제거
// .html 제거
const withoutExt = pagePath.replace('.html', '');
// 슬래시를 점으로 변환
const rawKey = withoutExt.replace(/\//g, '.');
return PAGE_KEY_ALIASES[rawKey] || rawKey;
}
/**
* 사용자의 페이지 접근 권한 확인 (캐시 활용)
*/
async function checkPageAccess(pageKey) {
const currentUser = getUser();
// Admin은 모든 페이지 접근 가능
if (currentUser.role === 'Admin' || currentUser.role === 'System Admin') {
return true;
}
// 프로필 페이지는 모든 사용자 접근 가능
if (pageKey && pageKey.startsWith('profile.')) {
return true;
}
// 대시보드는 모든 사용자 접근 가능
if (pageKey === 'dashboard') {
return true;
}
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 ' + (window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))
}
});
if (!response.ok) {
console.error('페이지 권한 조회 실패:', response.status);
return false;
}
const data = await response.json();
accessiblePages = data.data.pageAccess || [];
// 캐시 저장
localStorage.setItem('userPageAccess', JSON.stringify({
pages: accessiblePages,
timestamp: Date.now()
}));
}
// 해당 페이지에 대한 접근 권한 확인
const pageAccess = accessiblePages.find(p => p.page_key === pageKey);
return pageAccess && pageAccess.can_access === 1;
} catch (error) {
console.error('페이지 권한 체크 오류:', error);
return false;
}
}
// 쿠키 직접 읽기 (api-base.js의 cookieGet은 IIFE 내부 함수이므로 접근 불가)
function _authCookieGet(name) {
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
}
// 즉시 실행 함수로 스코프를 보호하고 로직을 실행
(async function() {
// 쿠키 우선 검증: 쿠키 없고 localStorage에만 토큰이 있으면 정리
var cookieToken = _authCookieGet('sso_token');
var localToken = localStorage.getItem('sso_token');
if (!cookieToken && localToken) {
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
'currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) { localStorage.removeItem(k); });
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
return;
}
if (!isLoggedIn()) {
clearAuthData(); // 만약을 위해 한번 더 정리
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
return; // 이후 코드 실행 방지
}
const currentUser = getUser();
// 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우)
if (!currentUser || !currentUser.username) {
console.error(' 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.');
clearAuthData();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
return;
}
const userRole = currentUser.role || currentUser.access_level || '사용자';
// 페이지 접근 권한 체크 (Admin은 건너뛰기)
if (currentUser.role !== 'Admin' && currentUser.role !== 'System Admin') {
const pageKey = getCurrentPageKey();
if (pageKey) {
const hasAccess = await checkPageAccess(pageKey);
if (!hasAccess) {
console.error(` 페이지 접근 권한이 없습니다: ${pageKey}`);
alert('이 페이지에 접근할 권한이 없습니다.');
window.location.href = '/pages/dashboard.html';
return;
}
}
}
// 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함.
// 전역 변수 할당(window.currentUser) 제거.
})();

View File

@@ -1,80 +0,0 @@
// /js/component-loader.js
import { config } from './config.js';
// 캐시 버전 (컴포넌트 변경 시 증가)
const CACHE_VERSION = 'v4';
/**
* 컴포넌트 HTML을 캐시에서 가져오거나 fetch
*/
async function getComponentHtml(componentName, componentPath) {
const cacheKey = `component_${componentName}_${CACHE_VERSION}`;
// 캐시에서 먼저 확인
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
return cached;
}
// 캐시 없으면 fetch
const response = await fetch(componentPath);
if (!response.ok) {
throw new Error(`컴포넌트 파일을 불러올 수 없습니다: ${response.statusText}`);
}
const htmlText = await response.text();
// 캐시에 저장
try {
sessionStorage.setItem(cacheKey, htmlText);
} catch (e) {
// sessionStorage 용량 초과 시 무시
}
return htmlText;
}
/**
* 공용 HTML 컴포넌트를 페이지의 특정 위치에 동적으로 로드합니다.
* @param {string} componentName - 로드할 컴포넌트의 이름 (e.g., 'sidebar', 'navbar'). config.js의 components 객체에 정의된 키와 일치해야 합니다.
* @param {string} containerSelector - 컴포넌트가 삽입될 DOM 요소의 CSS 선택자 (e.g., '#sidebar-container').
* @param {function(Document): void} [domProcessor=null] - DOM에 삽입하기 전에 로드된 HTML(Document)을 조작하는 선택적 함수.
* (e.g., 역할 기반 메뉴 필터링)
*/
export async function loadComponent(componentName, containerSelector, domProcessor = null) {
const container = document.querySelector(containerSelector);
if (!container) {
console.warn(` 컴포넌트를 삽입할 컨테이너를 찾을 수 없습니다: ${containerSelector} (선택사항일 수 있음)`);
return;
}
const componentPath = config.components[componentName];
if (!componentPath) {
console.error(` 설정 파일(config.js)에서 '${componentName}' 컴포넌트의 경로를 찾을 수 없습니다.`);
container.textContent = `${componentName} 로딩 실패`;
return;
}
try {
const htmlText = await getComponentHtml(componentName, componentPath);
if (domProcessor) {
// 1. 텍스트를 가상 DOM으로 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
// 2. DOM 프로세서(콜백)를 실행하여 DOM 조작
await domProcessor(doc);
// 3. 조작된 HTML을 실제 DOM에 삽입
container.innerHTML = doc.body.innerHTML;
} else {
// DOM 조작이 필요 없는 경우, 바로 삽입
container.innerHTML = htmlText;
}
} catch (error) {
console.error(` '${componentName}' 컴포넌트 로딩 실패:`, error);
container.textContent = `${componentName} 로딩에 실패했습니다. 관리자에게 문의하세요.`;
}
}

View File

@@ -1,469 +0,0 @@
// /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('sso_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();
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = config.paths.loginPage + '?redirect=' + encodeURIComponent('/pages/dashboard.html');
}
});
}
// 모바일 메뉴 버튼
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('sso_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('sso_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' });
}
// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공
// 메인 로직: 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);
}
});

View File

@@ -1,103 +0,0 @@
// /js/load-sections.js
import { getUser } from './auth.js';
import { apiGet } from './api-helper.js';
// 역할에 따라 불러올 섹션 HTML 파일을 매핑합니다.
const SECTION_MAP = {
admin: '/components/sections/admin-sections.html',
system: '/components/sections/admin-sections.html', // system도 admin과 동일한 섹션을 사용
leader: '/components/sections/leader-sections.html',
user: '/components/sections/user-sections.html',
default: '/components/sections/user-sections.html', // 역할이 없는 경우 기본값
};
/**
* API를 통해 대시보드 통계 데이터를 가져옵니다.
* @returns {Promise<object|null>} 통계 데이터 또는 에러 시 null
*/
async function fetchDashboardStats() {
try {
const today = new Date().toISOString().split('T')[0];
// 실제 백엔드 엔드포인트는 /api/dashboard/stats 와 같은 형태로 구현될 수 있습니다.
const stats = await apiGet(`/workreports?start=${today}&end=${today}`);
// 필요한 데이터 형태로 가공 (예시)
return {
today_reports_count: stats.length,
today_workers_count: new Set(stats.map(d => d.user_id)).size,
};
} catch (error) {
console.error('대시보드 통계 데이터 로드 실패:', error);
return null;
}
}
/**
* 가상 DOM에 통계 데이터를 채워 넣습니다.
* @param {Document} doc - 파싱된 HTML 문서 객체
* @param {object} stats - 통계 데이터
*/
function populateStatsData(doc, stats) {
if (!stats) return;
const todayStatsEl = doc.getElementById('today-stats');
if (todayStatsEl) {
todayStatsEl.innerHTML = `
<p>📝 오늘 등록된 작업: ${stats.today_reports_count}건</p>
<p>👥 참여 작업자: ${stats.today_workers_count}명</p>
`;
}
}
/**
* 메인 로직: 페이지에 역할별 섹션을 로드하고 내용을 채웁니다.
*/
async function initializeSections() {
const mainContainer = document.querySelector('main[id$="-sections"]');
if (!mainContainer) {
console.error('섹션을 담을 메인 컨테이너를 찾을 수 없습니다.');
return;
}
mainContainer.innerHTML = '<div class="loading">콘텐츠를 불러오는 중...</div>';
const currentUser = getUser();
if (!currentUser) {
mainContainer.innerHTML = '<div class="error-state">사용자 정보를 찾을 수 없습니다.</div>';
return;
}
const sectionFile = SECTION_MAP[currentUser.role] || SECTION_MAP.default;
try {
// 1. 역할에 맞는 HTML 템플릿과 동적 데이터를 동시에 로드 (Promise.all 활용)
const [htmlResponse, statsData] = await Promise.all([
fetch(sectionFile),
fetchDashboardStats()
]);
if (!htmlResponse.ok) {
throw new Error(`섹션 파일(${sectionFile})을 불러오는 데 실패했습니다.`);
}
const htmlText = await htmlResponse.text();
// 2. 텍스트를 가상 DOM으로 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
// 3. (필요 시) 역할 기반으로 가상 DOM 필터링 - 현재는 파일 자체가 역할별로 나뉘어 불필요
// filterByRole(doc, currentUser.role);
// 4. 가상 DOM에 동적 데이터 채우기
populateStatsData(doc, statsData);
// 5. 모든 수정이 완료된 HTML을 실제 DOM에 한 번에 삽입
mainContainer.innerHTML = doc.body.innerHTML;
} catch (error) {
console.error('섹션 로딩 중 오류 발생:', error);
mainContainer.textContent = '콘텐츠 로딩에 실패했습니다. 페이지를 새로고침해 주세요.';
}
}
// DOM이 로드되면 섹션 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializeSections);

View File

@@ -1,209 +0,0 @@
// /js/load-sidebar.js
// 사이드바 네비게이션 로더 및 컨트롤러
import { getUser } from './auth.js';
import { loadComponent } from './component-loader.js';
/**
* 사이드바 DOM을 사용자 권한에 맞게 처리
*/
async function processSidebarDom(doc) {
const currentUser = getUser();
if (!currentUser) return;
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';
// 1. 관리자 전용 메뉴 표시/숨김
if (isAdmin) {
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
} else {
// 비관리자: 페이지 접근 권한에 따라 메뉴 필터링
await filterMenuByPageAccess(doc, currentUser);
}
// 2. 현재 페이지 활성화
highlightCurrentPage(doc);
// 3. 저장된 상태 복원
restoreSidebarState(doc);
}
/**
* 사용자의 페이지 접근 권한에 따라 메뉴 필터링
*/
async function filterMenuByPageAccess(doc, currentUser) {
try {
const cached = localStorage.getItem('userPageAccess');
let accessiblePages = null;
if (cached) {
const cacheData = JSON.parse(cached);
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
accessiblePages = cacheData.pages;
}
}
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('sso_token')}`
}
});
if (!response.ok) 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);
// 메뉴 항목 필터링
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.style.display = 'none';
}
});
// 관리자 전용 카테고리 제거
doc.querySelectorAll('.nav-category.admin-only').forEach(el => el.remove());
} catch (error) {
console.error('사이드바 메뉴 필터링 오류:', error);
}
}
/**
* 현재 페이지 하이라이트
*/
function highlightCurrentPage(doc) {
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');
}
}
});
}
/**
* 사이드바 상태 복원 (기본값: 접힌 상태)
*/
function restoreSidebarState(doc) {
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 categoryName = cat.getAttribute('data-category');
if (categoryName) expanded.push(categoryName);
});
localStorage.setItem('sidebarExpanded', JSON.stringify(expanded));
});
});
// 링크 프리페치 - 마우스 올리면 미리 로드
const prefetchedUrls = new Set();
sidebar.querySelectorAll('a.nav-item').forEach(link => {
link.addEventListener('mouseenter', () => {
const href = link.getAttribute('href');
if (href && !prefetchedUrls.has(href) && !href.startsWith('#')) {
prefetchedUrls.add(href);
const prefetchLink = document.createElement('link');
prefetchLink.rel = 'prefetch';
prefetchLink.href = href;
document.head.appendChild(prefetchLink);
}
}, { once: true });
});
}
/**
* 사이드바 초기화
*/
async function initSidebar() {
// 사이드바 컨테이너가 없으면 생성
let container = document.getElementById('sidebar-container');
if (!container) {
container = document.createElement('div');
container.id = 'sidebar-container';
document.body.prepend(container);
}
if (getUser()) {
await loadComponent('sidebar-nav', '#sidebar-container', processSidebarDom);
document.body.classList.add('has-sidebar');
setupSidebarEvents();
}
}
// DOMContentLoaded 시 초기화
document.addEventListener('DOMContentLoaded', initSidebar);
export { initSidebar };

View File

@@ -1,496 +0,0 @@
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// 인증 확인
const token = ensureAuthenticated();
const accessLabels = {
worker: '작업자',
group_leader: '그룹장',
support_team: '지원팀',
admin: '관리자',
system: '시스템'
};
// 현재 사용자 정보 가져오기
const currentUser = JSON.parse(localStorage.getItem('sso_user') || '{}');
const isSystemUser = currentUser.access_level === 'system';
function createRow(item, cols, delHandler) {
const tr = document.createElement('tr');
cols.forEach(key => {
const td = document.createElement('td');
td.textContent = item[key] || '-';
tr.appendChild(td);
});
const delBtn = document.createElement('button');
delBtn.textContent = '삭제';
delBtn.className = 'btn-delete';
delBtn.onclick = () => delHandler(item);
const td = document.createElement('td');
td.appendChild(delBtn);
tr.appendChild(td);
return tr;
}
// 내 비밀번호 변경
const myPasswordForm = document.getElementById('myPasswordForm');
myPasswordForm?.addEventListener('submit', async e => {
e.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
// 새 비밀번호 확인
if (newPassword !== confirmPassword) {
alert('❌ 새 비밀번호가 일치하지 않습니다.');
return;
}
// 비밀번호 강도 검사
if (newPassword.length < 6) {
alert('❌ 비밀번호는 최소 6자 이상이어야 합니다.');
return;
}
try {
const res = await fetch(`${API}/auth/change-password`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
currentPassword,
newPassword
})
});
const result = await res.json();
if (res.ok && result.success) {
showToast('✅ 비밀번호가 변경되었습니다.');
myPasswordForm.reset();
// 3초 후 로그인 페이지로 이동
setTimeout(() => {
alert('비밀번호가 변경되어 다시 로그인해주세요.');
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
}, 2000);
} else {
alert('❌ 비밀번호 변경 실패: ' + (result.error || '현재 비밀번호가 올바르지 않습니다.'));
}
} catch (error) {
console.error('Password change error:', error);
alert('🚨 서버 오류: ' + error.message);
}
});
// 시스템 권한자만 볼 수 있는 사용자 비밀번호 변경 섹션
if (isSystemUser) {
const systemCard = document.getElementById('systemPasswordChangeCard');
if (systemCard) {
systemCard.style.display = 'block';
}
// 사용자 비밀번호 변경 (시스템 권한자)
const userPasswordForm = document.getElementById('userPasswordForm');
userPasswordForm?.addEventListener('submit', async e => {
e.preventDefault();
const targetUserId = document.getElementById('targetUserId').value;
const newPassword = document.getElementById('targetNewPassword').value;
if (!targetUserId) {
alert('❌ 사용자를 선택해주세요.');
return;
}
if (newPassword.length < 6) {
alert('❌ 비밀번호는 최소 6자 이상이어야 합니다.');
return;
}
if (!confirm('정말로 이 사용자의 비밀번호를 변경하시겠습니까?')) {
return;
}
try {
const res = await fetch(`${API}/auth/admin/change-password`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
userId: targetUserId,
newPassword
})
});
const result = await res.json();
if (res.ok && result.success) {
showToast('✅ 사용자 비밀번호가 변경되었습니다.');
userPasswordForm.reset();
} else {
alert('❌ 비밀번호 변경 실패: ' + (result.error || '권한이 없습니다.'));
}
} catch (error) {
console.error('Admin password change error:', error);
alert('🚨 서버 오류: ' + error.message);
}
});
}
// 사용자 등록
const userForm = document.getElementById('userForm');
userForm?.addEventListener('submit', async e => {
e.preventDefault();
const body = {
username: document.getElementById('username').value.trim(),
password: document.getElementById('password').value.trim(),
name: document.getElementById('name').value.trim(),
access_level: document.getElementById('access_level').value,
user_id: document.getElementById('user_id').value || null
};
try {
const res = await fetch(`${API}/auth/register`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(body)
});
const result = await res.json();
if (res.ok && result.success) {
showToast('✅ 등록 완료');
userForm.reset();
loadUsers();
} else {
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
}
} catch (error) {
console.error('Registration error:', error);
alert('🚨 서버 오류: ' + error.message);
}
});
async function loadUsers() {
const tbody = document.getElementById('userTableBody');
tbody.innerHTML = '<tr><td colspan="6">불러오는 중...</td></tr>';
try {
const res = await fetch(`${API}/auth/users`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const list = await res.json();
tbody.innerHTML = '';
if (Array.isArray(list)) {
// 시스템 권한자용 사용자 선택 옵션도 업데이트
if (isSystemUser) {
const targetUserSelect = document.getElementById('targetUserId');
if (targetUserSelect) {
targetUserSelect.innerHTML = '<option value="">사용자 선택</option>';
list.forEach(user => {
// 본인은 제외
if (user.user_id !== currentUser.user_id) {
const opt = document.createElement('option');
opt.value = user.user_id;
opt.textContent = `${user.name} (${user.username})`;
targetUserSelect.appendChild(opt);
}
});
}
}
list.forEach(item => {
item.access_level = accessLabels[item.access_level] || item.access_level;
item.user_id = item.user_id || '-';
// 행 생성
const tr = document.createElement('tr');
// 데이터 컬럼
['user_id', 'username', 'name', 'access_level', 'user_id'].forEach(key => {
const td = document.createElement('td');
td.textContent = item[key] || '-';
tr.appendChild(td);
});
// 작업 컬럼 (페이지 권한 버튼 + 삭제 버튼)
const actionTd = document.createElement('td');
// 페이지 권한 버튼 (Admin/System이 아닌 경우에만)
if (item.access_level !== '관리자' && item.access_level !== '시스템') {
const pageAccessBtn = document.createElement('button');
pageAccessBtn.textContent = '페이지 권한';
pageAccessBtn.className = 'btn btn-info btn-sm';
pageAccessBtn.style.marginRight = '5px';
pageAccessBtn.onclick = () => openPageAccessModal(item.user_id, item.username, item.name);
actionTd.appendChild(pageAccessBtn);
}
// 삭제 버튼
const delBtn = document.createElement('button');
delBtn.textContent = '삭제';
delBtn.className = 'btn-delete';
delBtn.onclick = async () => {
if (!confirm('삭제하시겠습니까?')) return;
try {
const delRes = await fetch(`${API}/auth/users/${item.user_id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (delRes.ok) {
showToast('✅ 삭제 완료');
loadUsers();
} else {
alert('❌ 삭제 실패');
}
} catch (error) {
alert('🚨 삭제 중 오류 발생');
}
};
actionTd.appendChild(delBtn);
tr.appendChild(actionTd);
tbody.appendChild(tr);
});
} else {
tbody.innerHTML = '<tr><td colspan="6">데이터 형식 오류</td></tr>';
}
} catch (error) {
console.error('Load users error:', error);
tbody.innerHTML = '<tr><td colspan="6">로드 실패: ' + error.message + '</td></tr>';
}
}
async function loadWorkerOptions() {
const select = document.getElementById('user_id');
if (!select) return;
try {
const res = await fetch(`${API}/workers`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const allWorkers = await res.json();
// 활성화된 작업자만 필터링
const workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
if (Array.isArray(workers)) {
workers.forEach(w => {
const opt = document.createElement('option');
opt.value = w.user_id;
opt.textContent = `${w.worker_name} (${w.user_id})`;
select.appendChild(opt);
});
}
} catch (error) {
console.warn('작업자 목록 불러오기 실패:', error);
}
}
// showToast → api-base.js 전역 사용
// ========== 페이지 접근 권한 관리 ==========
let currentEditingUserId = null;
let currentUserPageAccess = [];
/**
* 페이지 권한 관리 모달 열기
*/
async function openPageAccessModal(userId, username, name) {
currentEditingUserId = userId;
const modal = document.getElementById('pageAccessModal');
const modalUserInfo = document.getElementById('modalUserInfo');
const modalUserRole = document.getElementById('modalUserRole');
modalUserInfo.textContent = `${name} (${username})`;
modalUserRole.textContent = `사용자 ID: ${userId}`;
try {
// 사용자의 페이지 접근 권한 조회
const res = await fetch(`${API}/users/${userId}/page-access`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error('페이지 접근 권한을 불러오는데 실패했습니다.');
}
const result = await res.json();
if (result.success) {
currentUserPageAccess = result.data.pageAccess;
renderPageAccessList(result.data.pageAccess);
modal.style.display = 'block';
} else {
throw new Error(result.error || '데이터 로드 실패');
}
} catch (error) {
console.error('페이지 권한 로드 오류:', error);
alert('❌ 페이지 권한을 불러오는데 실패했습니다: ' + error.message);
}
}
/**
* 페이지 접근 권한 목록 렌더링
*/
function renderPageAccessList(pageAccess) {
const categories = {
dashboard: document.getElementById('dashboardPageList'),
management: document.getElementById('managementPageList'),
common: document.getElementById('commonPageList')
};
// 카테고리별로 초기화
Object.values(categories).forEach(el => {
if (el) el.innerHTML = '';
});
// 카테고리별로 그룹화
const grouped = pageAccess.reduce((acc, page) => {
if (!acc[page.category]) acc[page.category] = [];
acc[page.category].push(page);
return acc;
}, {});
// 각 카테고리별로 렌더링
Object.keys(grouped).forEach(category => {
const container = categories[category];
if (!container) return;
grouped[category].forEach(page => {
const pageItem = document.createElement('div');
pageItem.className = 'page-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `page_${page.page_id}`;
checkbox.checked = page.can_access === 1 || page.can_access === true;
checkbox.dataset.pageId = page.page_id;
const label = document.createElement('label');
label.htmlFor = `page_${page.page_id}`;
label.textContent = page.page_name;
const pathSpan = document.createElement('span');
pathSpan.className = 'page-path';
pathSpan.textContent = page.page_path;
pageItem.appendChild(checkbox);
pageItem.appendChild(label);
pageItem.appendChild(pathSpan);
container.appendChild(pageItem);
});
});
}
/**
* 페이지 권한 변경 사항 저장
*/
async function savePageAccessChanges() {
if (!currentEditingUserId) {
alert('사용자 정보가 없습니다.');
return;
}
// 모든 체크박스 상태 가져오기
const checkboxes = document.querySelectorAll('.page-item input[type="checkbox"]');
const pageAccessUpdates = {};
checkboxes.forEach(checkbox => {
const pageId = parseInt(checkbox.dataset.pageId);
const canAccess = checkbox.checked;
pageAccessUpdates[pageId] = canAccess;
});
try {
// 변경된 페이지 권한을 서버로 전송
const pageIds = Object.keys(pageAccessUpdates).map(id => parseInt(id));
const canAccessValues = pageIds.map(id => pageAccessUpdates[id]);
// 접근 가능한 페이지
const accessiblePages = pageIds.filter((id, index) => canAccessValues[index]);
// 접근 불가능한 페이지
const inaccessiblePages = pageIds.filter((id, index) => !canAccessValues[index]);
// 접근 가능 페이지 업데이트
if (accessiblePages.length > 0) {
await fetch(`${API}/users/${currentEditingUserId}/page-access`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
pageIds: accessiblePages,
canAccess: true
})
});
}
// 접근 불가능 페이지 업데이트
if (inaccessiblePages.length > 0) {
await fetch(`${API}/users/${currentEditingUserId}/page-access`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
pageIds: inaccessiblePages,
canAccess: false
})
});
}
showToast('✅ 페이지 접근 권한이 저장되었습니다.');
closePageAccessModal();
} catch (error) {
console.error('페이지 권한 저장 오류:', error);
alert('❌ 페이지 권한 저장에 실패했습니다: ' + error.message);
}
}
/**
* 페이지 권한 관리 모달 닫기
*/
function closePageAccessModal() {
const modal = document.getElementById('pageAccessModal');
modal.style.display = 'none';
currentEditingUserId = null;
currentUserPageAccess = [];
}
// 모달 닫기 버튼 이벤트
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('pageAccessModal');
const closeBtn = modal?.querySelector('.close');
if (closeBtn) {
closeBtn.onclick = closePageAccessModal;
}
// 모달 외부 클릭 시 닫기
window.onclick = (event) => {
if (event.target === modal) {
closePageAccessModal();
}
};
});
// 전역 함수로 노출
window.openPageAccessModal = openPageAccessModal;
window.closePageAccessModal = closePageAccessModal;
window.savePageAccessChanges = savePageAccessChanges;
window.addEventListener('DOMContentLoaded', () => {
loadUsers();
loadWorkerOptions();
});

View File

@@ -12,7 +12,6 @@
var categories = [];
var allWorkplaces = [];
var tbmByWorkplace = {};
var visitorsByWorkplace = {};
var movedByWorkplace = {};
var issuesByWorkplace = {};
var workplacesByCategory = {};
@@ -37,24 +36,6 @@
});
}
function groupVisitorsByWorkplace(requests) {
visitorsByWorkplace = {};
if (!Array.isArray(requests)) return;
requests.forEach(function(r) {
// 오늘 날짜 + 승인된 건만
if (r.visit_date !== today) return;
if (r.status !== 'approved') return;
var wpId = r.workplace_id;
if (!wpId) return;
if (!visitorsByWorkplace[wpId]) {
visitorsByWorkplace[wpId] = { visitCount: 0, totalVisitors: 0, requests: [] };
}
visitorsByWorkplace[wpId].visitCount++;
visitorsByWorkplace[wpId].totalVisitors += parseInt(r.visitor_count) || 0;
visitorsByWorkplace[wpId].requests.push(r);
});
}
function groupMovedByWorkplace(items) {
movedByWorkplace = {};
if (!Array.isArray(items)) return;
@@ -158,11 +139,10 @@
workplaces.forEach(function(wp) {
var wpId = wp.workplace_id;
var tbm = tbmByWorkplace[wpId];
var visitors = visitorsByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var hasAny = tbm || visitors || moved || issues;
var hasAny = tbm || moved || issues;
html += '<div class="md-wp-card" data-wp-id="' + wpId + '">';
@@ -187,14 +167,6 @@
'</div>';
}
// 방문
if (visitors) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#128682;</span>' +
'<span class="md-wp-stat-text">방문 ' + visitors.visitCount + '건 &middot; ' + visitors.totalVisitors + '명</span>' +
'</div>';
}
// 신고 (미완료만)
if (issues && issues.activeCount > 0) {
html += '<div class="md-wp-stat-row md-wp-stat--warning">' +
@@ -230,7 +202,7 @@
var cards = container.querySelectorAll('.md-wp-card[data-wp-id]');
cards.forEach(function(card) {
var wpId = card.getAttribute('data-wp-id');
var hasActivity = tbmByWorkplace[wpId] || visitorsByWorkplace[wpId] ||
var hasActivity = tbmByWorkplace[wpId] ||
movedByWorkplace[wpId] || issuesByWorkplace[wpId];
if (!hasActivity) return;
card.querySelector('.md-wp-header').addEventListener('click', function() {
@@ -262,7 +234,6 @@
function renderCardDetail(wpId) {
var html = '';
var tbm = tbmByWorkplace[wpId];
var visitors = visitorsByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
@@ -282,23 +253,6 @@
html += '</div>';
}
// 방문
if (visitors && visitors.requests.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 방문</div>';
visitors.requests.forEach(function(r) {
var company = r.visitor_company || '업체 미지정';
var count = parseInt(r.visitor_count) || 0;
var purpose = r.purpose_name || '';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(company) + ' &middot; ' + count + '명';
if (purpose) html += ' &middot; ' + escapeHtml(purpose);
html += '</div>';
html += '</div>';
});
html += '</div>';
}
// 신고
if (issues && issues.items.length > 0) {
var statusMap = { reported: '신고', received: '접수', in_progress: '처리중' };
@@ -380,7 +334,6 @@
var results = await Promise.allSettled([
window.apiCall('/workplaces/categories'),
window.apiCall('/tbm/sessions/date/' + today),
window.apiCall('/workplace-visits/requests?visit_date=' + today + '&status=approved'),
window.apiCall('/equipments/moved/list'),
window.apiCall('/work-issues?start_date=' + today + '&end_date=' + today),
window.apiCall('/workplaces')
@@ -396,24 +349,19 @@
groupTbmByWorkplace(results[1].value.data || []);
}
// 방문
if (results[2].status === 'fulfilled' && results[2].value && results[2].value.success) {
groupVisitorsByWorkplace(results[2].value.data || []);
}
// 이동설비
if (results[3].status === 'fulfilled' && results[3].value && results[3].value.success) {
groupMovedByWorkplace(results[3].value.data || []);
if (results[2].status === 'fulfilled' && results[2].value && results[2].value.success) {
groupMovedByWorkplace(results[2].value.data || []);
}
// 신고
if (results[4].status === 'fulfilled' && results[4].value && results[4].value.success) {
groupIssuesByWorkplace(results[4].value.data || []);
if (results[3].status === 'fulfilled' && results[3].value && results[3].value.success) {
groupIssuesByWorkplace(results[3].value.data || []);
}
// 작업장 전체 (카테고리별 그룹핑)
if (results[5].status === 'fulfilled' && results[5].value && results[5].value.success) {
allWorkplaces = results[5].value.data || [];
if (results[4].status === 'fulfilled' && results[4].value && results[4].value.success) {
allWorkplaces = results[4].value.data || [];
groupWorkplacesByCategory(allWorkplaces);
}

View File

@@ -1,52 +0,0 @@
// /js/navigation.js
import { config } from './config.js';
/**
* 지정된 URL로 페이지를 리디렉션합니다.
* @param {string} url - 이동할 URL
*/
function redirect(url) {
window.location.href = url;
}
/**
* 로그인 페이지로 리디렉션합니다.
*/
export function redirectToLogin() {
const loginUrl = config.paths.loginPage + '?redirect=' + encodeURIComponent(window.location.href);
redirect(loginUrl);
}
/**
* 사용자의 기본 대시보드 페이지로 리디렉션합니다.
* 백엔드가 지정한 URL이 있으면 그곳으로, 없으면 기본 URL로 이동합니다.
* @param {string} [backendRedirectUrl=null] - 백엔드에서 전달받은 리디렉션 URL
*/
export function redirectToDefaultDashboard(backendRedirectUrl = null) {
const destination = backendRedirectUrl || config.paths.defaultDashboard;
// 부드러운 화면 전환 효과
document.body.style.transition = 'opacity 0.3s ease-out';
document.body.style.opacity = '0';
setTimeout(() => {
redirect(destination);
}, 300);
}
/**
* 시스템 대시보드 페이지로 리디렉션합니다.
*/
export function redirectToSystemDashboard() {
redirect(config.paths.systemDashboard);
}
/**
* 그룹 리더 대시보드 페이지로 리디렉션합니다.
*/
export function redirectToGroupLeaderDashboard() {
redirect(config.paths.groupLeaderDashboard);
}
// 필요에 따라 더 많은 리디렉션 함수를 추가할 수 있습니다.
// export function redirectToUserProfile() { ... }

View File

@@ -1,119 +0,0 @@
// /js/page-access-cache.js
// 페이지 권한 캐시 - 중복 API 호출 방지
const CACHE_KEY = 'userPageAccess';
const CACHE_DURATION = 10 * 60 * 1000; // 10분
// 진행 중인 API 호출 Promise (중복 방지)
let fetchPromise = null;
/**
* 페이지 접근 권한 데이터 가져오기 (캐시 우선)
* @param {object} currentUser - 현재 사용자 객체
* @returns {Promise<Array>} 접근 가능한 페이지 목록
*/
export async function getPageAccess(currentUser) {
if (!currentUser || !currentUser.user_id) {
return null;
}
// 1. 캐시 확인
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const cacheData = JSON.parse(cached);
if (Date.now() - cacheData.timestamp < CACHE_DURATION) {
return cacheData.pages;
}
} catch (e) {
localStorage.removeItem(CACHE_KEY);
}
}
// 2. 이미 API 호출 중이면 기존 Promise 반환
if (fetchPromise) {
return fetchPromise;
}
// 3. 새로운 API 호출
fetchPromise = (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('sso_token')}`
}
});
if (!response.ok) {
console.error('페이지 권한 조회 실패:', response.status);
return null;
}
const data = await response.json();
const accessiblePages = data.data.pageAccess || [];
// 캐시 저장
localStorage.setItem(CACHE_KEY, JSON.stringify({
pages: accessiblePages,
timestamp: Date.now()
}));
return accessiblePages;
} catch (error) {
console.error('페이지 권한 조회 오류:', error);
return null;
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
/**
* 특정 페이지에 대한 접근 권한 확인
* @param {string} pageKey - 페이지 키
* @param {object} currentUser - 현재 사용자 객체
* @returns {Promise<boolean>}
*/
export async function hasPageAccess(pageKey, currentUser) {
// Admin은 모든 페이지 접근 가능
if (currentUser.role === 'Admin' || currentUser.role === 'System Admin') {
return true;
}
// 대시보드, 프로필은 모든 사용자 접근 가능
if (pageKey === 'dashboard' || (pageKey && pageKey.startsWith('profile.'))) {
return true;
}
const pages = await getPageAccess(currentUser);
if (!pages) return false;
const pageAccess = pages.find(p => p.page_key === pageKey);
return pageAccess && pageAccess.can_access === 1;
}
/**
* 접근 가능한 페이지 키 목록 반환
* @param {object} currentUser
* @returns {Promise<string[]>}
*/
export 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);
}
/**
* 캐시 초기화
*/
export function clearPageAccessCache() {
localStorage.removeItem(CACHE_KEY);
fetchPromise = null;
}

View File

@@ -1,850 +0,0 @@
/**
* 안전 체크리스트 관리 페이지 스크립트
*
* 3가지 유형의 체크리스트 항목을 관리:
* 1. 기본 사항 - 항상 표시
* 2. 날씨별 - 날씨 조건에 따라 표시
* 3. 작업별 - 선택한 작업에 따라 표시
*
* @since 2026-02-02
*/
import { apiCall } from './api-config.js';
// 전역 상태
let allChecks = [];
let weatherConditions = [];
let workTypes = [];
let tasks = [];
let currentTab = 'basic';
let editingCheckId = null;
// 카테고리 정보
const CATEGORIES = {
PPE: { name: 'PPE (개인보호장비)', icon: '🦺' },
EQUIPMENT: { name: 'EQUIPMENT (장비점검)', icon: '🔧' },
ENVIRONMENT: { name: 'ENVIRONMENT (작업환경)', icon: '🏗️' },
EMERGENCY: { name: 'EMERGENCY (비상대응)', icon: '🚨' },
WEATHER: { name: 'WEATHER (날씨)', icon: '🌤️' },
TASK: { name: 'TASK (작업)', icon: '📋' }
};
// 날씨 아이콘 매핑
const WEATHER_ICONS = {
clear: '☀️',
rain: '🌧️',
snow: '❄️',
heat: '🔥',
cold: '🥶',
wind: '💨',
fog: '🌫️',
dust: '😷'
};
/**
* 페이지 초기화
*/
async function initPage() {
try {
await Promise.all([
loadAllChecks(),
loadWeatherConditions(),
loadWorkTypes()
]);
renderCurrentTab();
} catch (error) {
console.error('초기화 실패:', error);
showToast('데이터를 불러오는데 실패했습니다.', 'error');
}
}
// DOMContentLoaded 이벤트
document.addEventListener('DOMContentLoaded', initPage);
/**
* 모든 안전 체크 항목 로드
*/
async function loadAllChecks() {
try {
const response = await apiCall('/tbm/safety-checks');
if (response && response.success) {
allChecks = response.data || [];
} else {
console.warn('체크 항목 응답 실패:', response);
allChecks = [];
}
} catch (error) {
console.error('체크 항목 로드 실패:', error);
allChecks = [];
}
}
/**
* 날씨 조건 목록 로드
*/
async function loadWeatherConditions() {
try {
const response = await apiCall('/tbm/weather/conditions');
if (response && response.success) {
weatherConditions = response.data || [];
populateWeatherSelects();
}
} catch (error) {
console.error('날씨 조건 로드 실패:', error);
weatherConditions = [];
}
}
/**
* 공정(작업 유형) 목록 로드
*/
async function loadWorkTypes() {
try {
const response = await apiCall('/daily-work-reports/work-types');
if (response && response.success) {
workTypes = response.data || [];
populateWorkTypeSelects();
}
} catch (error) {
console.error('공정 목록 로드 실패:', error);
workTypes = [];
}
}
/**
* 날씨 조건 셀렉트 박스 채우기
*/
function populateWeatherSelects() {
const filterSelect = document.getElementById('weatherFilter');
const modalSelect = document.getElementById('weatherCondition');
const options = weatherConditions.map(wc =>
`<option value="${wc.condition_code}">${WEATHER_ICONS[wc.condition_code] || ''} ${wc.condition_name}</option>`
).join('');
if (filterSelect) {
filterSelect.innerHTML = `<option value="">모든 날씨 조건</option>${options}`;
}
if (modalSelect) {
modalSelect.innerHTML = options || '<option value="">날씨 조건 없음</option>';
}
}
/**
* 공정 셀렉트 박스 채우기
*/
function populateWorkTypeSelects() {
const filterSelect = document.getElementById('workTypeFilter');
const modalSelect = document.getElementById('modalWorkType');
const options = workTypes.map(wt =>
`<option value="${wt.id}">${wt.name}</option>`
).join('');
if (filterSelect) {
filterSelect.innerHTML = `<option value="">공정 선택</option>${options}`;
}
if (modalSelect) {
modalSelect.innerHTML = `<option value="">공정 선택</option>${options}`;
}
}
/**
* 탭 전환
*/
function switchTab(tabName) {
currentTab = tabName;
// 탭 버튼 상태 업데이트
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// 탭 콘텐츠 표시/숨김
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === `${tabName}Tab`);
});
renderCurrentTab();
}
/**
* 현재 탭 렌더링
*/
function renderCurrentTab() {
switch (currentTab) {
case 'basic':
renderBasicChecks();
break;
case 'weather':
renderWeatherChecks();
break;
case 'task':
renderTaskChecks();
break;
}
}
/**
* 기본 체크 항목 렌더링
*/
function renderBasicChecks() {
const container = document.getElementById('basicChecklistContainer');
const basicChecks = allChecks.filter(c => c.check_type === 'basic');
console.log('기본 체크항목:', basicChecks.length, '개');
if (basicChecks.length === 0) {
container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.') + renderInlineAddStandalone('basic');
return;
}
// 카테고리별로 그룹화
const grouped = groupByCategory(basicChecks);
container.innerHTML = Object.entries(grouped).map(([category, items]) =>
renderChecklistGroup(category, items)
).join('') + renderInlineAddStandalone('basic');
}
/**
* 날씨별 체크 항목 렌더링
*/
function renderWeatherChecks() {
const container = document.getElementById('weatherChecklistContainer');
const filterValue = document.getElementById('weatherFilter')?.value;
let weatherChecks = allChecks.filter(c => c.check_type === 'weather');
if (filterValue) {
weatherChecks = weatherChecks.filter(c => c.weather_condition === filterValue);
}
const inlineRow = filterValue ? renderInlineAddStandalone('weather') : '';
if (weatherChecks.length === 0) {
container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.') + inlineRow;
return;
}
// 날씨 조건별로 그룹화
const grouped = groupByWeather(weatherChecks);
container.innerHTML = Object.entries(grouped).map(([condition, items]) => {
const conditionInfo = weatherConditions.find(wc => wc.condition_code === condition);
const icon = WEATHER_ICONS[condition] || '🌤️';
const name = conditionInfo?.condition_name || condition;
return renderChecklistGroup(`${icon} ${name}`, items, condition);
}).join('') + inlineRow;
}
/**
* 작업별 체크 항목 렌더링
*/
function renderTaskChecks() {
const container = document.getElementById('taskChecklistContainer');
const workTypeId = document.getElementById('workTypeFilter')?.value;
const taskId = document.getElementById('taskFilter')?.value;
// 공정 미선택 시 안내
if (!workTypeId) {
container.innerHTML = renderGuideState('공정을 먼저 선택해주세요.');
return;
}
let taskChecks = allChecks.filter(c => c.check_type === 'task');
if (taskId) {
taskChecks = taskChecks.filter(c => c.task_id == taskId);
} else if (workTypeId && tasks.length > 0) {
const workTypeTasks = tasks.filter(t => t.work_type_id == workTypeId);
const taskIds = workTypeTasks.map(t => t.task_id);
taskChecks = taskChecks.filter(c => taskIds.includes(c.task_id));
}
const inlineRow = taskId ? renderInlineAddStandalone('task') : '';
if (taskChecks.length === 0) {
container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.') + inlineRow;
return;
}
// 작업별로 그룹화
const grouped = groupByTask(taskChecks);
container.innerHTML = Object.entries(grouped).map(([taskId, items]) => {
const task = tasks.find(t => t.task_id == taskId);
const taskName = task?.task_name || `작업 ${taskId}`;
return renderChecklistGroup(`📋 ${taskName}`, items, null, taskId);
}).join('') + inlineRow;
}
/**
* 카테고리별 그룹화
*/
function groupByCategory(checks) {
return checks.reduce((acc, check) => {
const category = check.check_category || 'OTHER';
if (!acc[category]) acc[category] = [];
acc[category].push(check);
return acc;
}, {});
}
/**
* 날씨 조건별 그룹화
*/
function groupByWeather(checks) {
return checks.reduce((acc, check) => {
const condition = check.weather_condition || 'other';
if (!acc[condition]) acc[condition] = [];
acc[condition].push(check);
return acc;
}, {});
}
/**
* 작업별 그룹화
*/
function groupByTask(checks) {
return checks.reduce((acc, check) => {
const taskId = check.task_id || 0;
if (!acc[taskId]) acc[taskId] = [];
acc[taskId].push(check);
return acc;
}, {});
}
/**
* 체크리스트 그룹 렌더링
*/
function renderChecklistGroup(title, items, weatherCondition = null, taskId = null) {
const categoryInfo = CATEGORIES[title] || { name: title, icon: '' };
const displayTitle = categoryInfo.name !== title ? categoryInfo.name : title;
const icon = categoryInfo.icon || '';
// 표시 순서로 정렬
items.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
return `
<div class="checklist-group">
<div class="group-header">
<div class="group-title">
<span class="group-icon">${icon}</span>
<span>${displayTitle}</span>
</div>
<span class="group-count">${items.length}개</span>
</div>
<div class="checklist-items">
${items.map(item => renderChecklistItem(item)).join('')}
</div>
</div>
`;
}
/**
* 체크리스트 항목 렌더링
*/
function renderChecklistItem(item) {
const requiredBadge = item.is_required
? '<span class="item-badge badge-required">필수</span>'
: '<span class="item-badge badge-optional">선택</span>';
return `
<div class="checklist-item" data-check-id="${item.check_id}">
<div class="item-info">
<div class="item-name">${item.check_item}</div>
<div class="item-meta">
${requiredBadge}
${item.description ? `<span>${item.description}</span>` : ''}
</div>
</div>
<div class="item-actions">
<button class="btn-icon btn-edit" onclick="openEditModal(${item.check_id})" title="수정">
✏️
</button>
<button class="btn-icon btn-delete" onclick="confirmDelete(${item.check_id})" title="삭제">
🗑️
</button>
</div>
</div>
`;
}
/**
* 빈 상태 렌더링
*/
function renderEmptyState(message) {
return `
<div class="empty-state">
<div class="empty-state-icon">📋</div>
<p>${message}</p>
</div>
`;
}
/**
* 안내 상태 렌더링 (필터 미선택 시)
*/
function renderGuideState(message) {
return `
<div class="empty-state">
<div class="empty-state-icon">👆</div>
<p>${message}</p>
</div>
`;
}
/**
* 날씨 필터 변경
*/
function filterByWeather() {
renderWeatherChecks();
}
/**
* 공정 필터 변경
*/
async function filterByWorkType() {
const workTypeId = document.getElementById('workTypeFilter')?.value;
const taskSelect = document.getElementById('taskFilter');
// workTypeId가 없거나 빈 문자열이면 early return
if (!workTypeId || workTypeId === '' || workTypeId === 'undefined') {
if (taskSelect) {
taskSelect.innerHTML = '<option value="">작업 선택</option>';
}
tasks = [];
renderTaskChecks();
return;
}
try {
const response = await apiCall(`/tasks/by-work-type/${workTypeId}`);
if (response && response.success) {
tasks = response.data || [];
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
tasks.map(t => `<option value="${t.task_id}">${t.task_name}</option>`).join('');
}
} catch (error) {
console.error('작업 목록 로드 실패:', error);
tasks = [];
}
renderTaskChecks();
}
/**
* 작업 필터 변경
*/
function filterByTask() {
renderTaskChecks();
}
/**
* 모달의 작업 목록 로드
*/
async function loadModalTasks() {
const workTypeId = document.getElementById('modalWorkType')?.value;
const taskSelect = document.getElementById('modalTask');
// workTypeId가 없거나 빈 문자열이면 early return
if (!workTypeId || workTypeId === '' || workTypeId === 'undefined') {
if (taskSelect) {
taskSelect.innerHTML = '<option value="">작업 선택</option>';
}
return;
}
try {
const response = await apiCall(`/tasks/by-work-type/${workTypeId}`);
if (response && response.success) {
const modalTasks = response.data || [];
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
modalTasks.map(t => `<option value="${t.task_id}">${t.task_name}</option>`).join('');
}
} catch (error) {
console.error('작업 목록 로드 실패:', error);
}
}
/**
* 조건부 필드 토글
*/
function toggleConditionalFields() {
const checkType = document.querySelector('input[name="checkType"]:checked')?.value;
document.getElementById('basicFields').classList.toggle('show', checkType === 'basic');
document.getElementById('weatherFields').classList.toggle('show', checkType === 'weather');
document.getElementById('taskFields').classList.toggle('show', checkType === 'task');
}
/**
* 추가 모달 열기
*/
function openAddModal() {
editingCheckId = null;
document.getElementById('modalTitle').textContent = '체크 항목 추가';
// 폼 초기화
document.getElementById('checkForm').reset();
document.getElementById('checkId').value = '';
// 현재 탭에 맞는 유형 선택
const typeRadio = document.querySelector(`input[name="checkType"][value="${currentTab}"]`);
if (typeRadio) {
typeRadio.checked = true;
}
toggleConditionalFields();
// 날씨별 탭: 현재 필터의 날씨 조건 반영
if (currentTab === 'weather') {
const weatherFilter = document.getElementById('weatherFilter')?.value;
if (weatherFilter) {
document.getElementById('weatherCondition').value = weatherFilter;
}
}
// 작업별 탭: 현재 필터의 공정/작업 반영
if (currentTab === 'task') {
const workTypeId = document.getElementById('workTypeFilter')?.value;
if (workTypeId) {
document.getElementById('modalWorkType').value = workTypeId;
loadModalTasks().then(() => {
const taskId = document.getElementById('taskFilter')?.value;
if (taskId) {
document.getElementById('modalTask').value = taskId;
}
});
}
}
showModal();
}
/**
* 수정 모달 열기
*/
async function openEditModal(checkId) {
editingCheckId = checkId;
const check = allChecks.find(c => c.check_id === checkId);
if (!check) {
showToast('항목을 찾을 수 없습니다.', 'error');
return;
}
document.getElementById('modalTitle').textContent = '체크 항목 수정';
document.getElementById('checkId').value = checkId;
// 유형 선택
const typeRadio = document.querySelector(`input[name="checkType"][value="${check.check_type}"]`);
if (typeRadio) {
typeRadio.checked = true;
}
toggleConditionalFields();
// 카테고리
if (check.check_type === 'basic') {
document.getElementById('checkCategory').value = check.check_category || 'PPE';
}
// 날씨 조건
if (check.check_type === 'weather') {
document.getElementById('weatherCondition').value = check.weather_condition || '';
}
// 작업
if (check.check_type === 'task' && check.task_id) {
// 먼저 공정 찾기 (task를 통해)
const task = tasks.find(t => t.task_id === check.task_id);
if (task) {
document.getElementById('modalWorkType').value = task.work_type_id;
await loadModalTasks();
document.getElementById('modalTask').value = check.task_id;
}
}
// 공통 필드
document.getElementById('checkItem').value = check.check_item || '';
document.getElementById('checkDescription').value = check.description || '';
document.getElementById('isRequired').checked = check.is_required === 1 || check.is_required === true;
document.getElementById('displayOrder').value = check.display_order || 0;
showModal();
}
/**
* 모달 표시
*/
function showModal() {
document.getElementById('checkModal').style.display = 'flex';
}
/**
* 모달 닫기
*/
function closeModal() {
document.getElementById('checkModal').style.display = 'none';
editingCheckId = null;
}
/**
* 체크 항목 저장
*/
async function saveCheck() {
const checkType = document.querySelector('input[name="checkType"]:checked')?.value;
const checkItem = document.getElementById('checkItem').value.trim();
if (!checkItem) {
showToast('체크 항목을 입력해주세요.', 'error');
return;
}
const data = {
check_type: checkType,
check_item: checkItem,
description: document.getElementById('checkDescription').value.trim() || null,
is_required: document.getElementById('isRequired').checked,
display_order: parseInt(document.getElementById('displayOrder').value) || 0
};
// 유형별 추가 데이터
switch (checkType) {
case 'basic':
data.check_category = document.getElementById('checkCategory').value;
break;
case 'weather':
data.check_category = 'WEATHER';
data.weather_condition = document.getElementById('weatherCondition').value;
if (!data.weather_condition) {
showToast('날씨 조건을 선택해주세요.', 'error');
return;
}
break;
case 'task':
data.check_category = 'TASK';
data.task_id = document.getElementById('modalTask').value;
if (!data.task_id) {
showToast('작업을 선택해주세요.', 'error');
return;
}
break;
}
try {
let response;
if (editingCheckId) {
// 수정
response = await apiCall(`/tbm/safety-checks/${editingCheckId}`, 'PUT', data);
} else {
// 추가
response = await apiCall('/tbm/safety-checks', 'POST', data);
}
if (response && response.success) {
showToast(editingCheckId ? '항목이 수정되었습니다.' : '항목이 추가되었습니다.', 'success');
closeModal();
await loadAllChecks();
renderCurrentTab();
} else {
showToast(response?.message || '저장에 실패했습니다.', 'error');
}
} catch (error) {
console.error('저장 실패:', error);
showToast('저장 중 오류가 발생했습니다.', 'error');
}
}
/**
* 삭제 확인
*/
function confirmDelete(checkId) {
const check = allChecks.find(c => c.check_id === checkId);
if (!check) {
showToast('항목을 찾을 수 없습니다.', 'error');
return;
}
if (confirm(`"${check.check_item}" 항목을 삭제하시겠습니까?`)) {
deleteCheck(checkId);
}
}
/**
* 체크 항목 삭제
*/
async function deleteCheck(checkId) {
try {
const response = await apiCall(`/tbm/safety-checks/${checkId}`, 'DELETE');
if (response && response.success) {
showToast('항목이 삭제되었습니다.', 'success');
await loadAllChecks();
renderCurrentTab();
} else {
showToast(response?.message || '삭제에 실패했습니다.', 'error');
}
} catch (error) {
console.error('삭제 실패:', error);
showToast('삭제 중 오류가 발생했습니다.', 'error');
}
}
/**
* 인라인 추가 행 렌더링
*/
function renderInlineAddRow(tabType) {
if (tabType === 'basic') {
const categoryOptions = Object.entries(CATEGORIES)
.filter(([key]) => !['WEATHER', 'TASK'].includes(key))
.map(([key, val]) => `<option value="${key}">${val.name}</option>`)
.join('');
return `
<div class="inline-add-row">
<select class="inline-add-select" id="inlineCategory">${categoryOptions}</select>
<input type="text" class="inline-add-input" id="inlineBasicInput"
placeholder="새 체크 항목 입력..." onkeydown="if(event.key==='Enter'){event.preventDefault();addInlineCheck('basic');}">
<button class="inline-add-btn" onclick="addInlineCheck('basic')">추가</button>
</div>
`;
}
if (tabType === 'weather') {
return `
<div class="inline-add-row">
<input type="text" class="inline-add-input" id="inlineWeatherInput"
placeholder="새 날씨별 체크 항목 입력..." onkeydown="if(event.key==='Enter'){event.preventDefault();addInlineCheck('weather');}">
<button class="inline-add-btn" onclick="addInlineCheck('weather')">추가</button>
</div>
`;
}
if (tabType === 'task') {
return `
<div class="inline-add-row">
<input type="text" class="inline-add-input" id="inlineTaskInput"
placeholder="새 작업별 체크 항목 입력..." onkeydown="if(event.key==='Enter'){event.preventDefault();addInlineCheck('task');}">
<button class="inline-add-btn" onclick="addInlineCheck('task')">추가</button>
</div>
`;
}
return '';
}
/**
* 인라인 추가 행을 standalone 컨테이너로 감싸기 (빈 상태용)
*/
function renderInlineAddStandalone(tabType) {
return `<div class="inline-add-standalone">${renderInlineAddRow(tabType)}</div>`;
}
/**
* 인라인으로 체크 항목 추가
*/
async function addInlineCheck(tabType) {
let checkItem, data;
if (tabType === 'basic') {
const input = document.getElementById('inlineBasicInput');
const categorySelect = document.getElementById('inlineCategory');
checkItem = input?.value.trim();
if (!checkItem) { input?.focus(); return; }
data = {
check_type: 'basic',
check_item: checkItem,
check_category: categorySelect?.value || 'PPE',
is_required: true,
display_order: 0
};
} else if (tabType === 'weather') {
const input = document.getElementById('inlineWeatherInput');
checkItem = input?.value.trim();
if (!checkItem) { input?.focus(); return; }
const weatherFilter = document.getElementById('weatherFilter')?.value;
if (!weatherFilter) {
showToast('날씨 조건을 먼저 선택해주세요.', 'error');
return;
}
data = {
check_type: 'weather',
check_item: checkItem,
check_category: 'WEATHER',
weather_condition: weatherFilter,
is_required: true,
display_order: 0
};
} else if (tabType === 'task') {
const input = document.getElementById('inlineTaskInput');
checkItem = input?.value.trim();
if (!checkItem) { input?.focus(); return; }
const taskId = document.getElementById('taskFilter')?.value;
if (!taskId) {
showToast('작업을 먼저 선택해주세요.', 'error');
return;
}
data = {
check_type: 'task',
check_item: checkItem,
check_category: 'TASK',
task_id: parseInt(taskId),
is_required: true,
display_order: 0
};
} else {
return;
}
try {
const response = await apiCall('/tbm/safety-checks', 'POST', data);
if (response && response.success) {
showToast('항목이 추가되었습니다.', 'success');
await loadAllChecks();
renderCurrentTab();
} else {
showToast(response?.message || '추가에 실패했습니다.', 'error');
}
} catch (error) {
console.error('인라인 추가 실패:', error);
showToast('추가 중 오류가 발생했습니다.', 'error');
}
}
// showToast → api-base.js 전역 사용
// 모달 외부 클릭 시 닫기
document.getElementById('checkModal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
// HTML onclick에서 호출할 수 있도록 전역에 노출
window.switchTab = switchTab;
window.openAddModal = openAddModal;
window.openEditModal = openEditModal;
window.closeModal = closeModal;
window.saveCheck = saveCheck;
window.confirmDelete = confirmDelete;
window.filterByWeather = filterByWeather;
window.filterByWorkType = filterByWorkType;
window.filterByTask = filterByTask;
window.loadModalTasks = loadModalTasks;
window.toggleConditionalFields = toggleConditionalFields;
window.addInlineCheck = addInlineCheck;

View File

@@ -1,368 +0,0 @@
// 안전관리 대시보드 JavaScript
let currentStatus = 'pending';
let requests = [];
let currentRejectRequestId = null;
// showToast → api-base.js 전역 사용
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async () => {
await loadRequests();
updateStats();
});
// ==================== 데이터 로드 ====================
/**
* 출입 신청 목록 로드
*/
async function loadRequests() {
try {
const filters = currentStatus === 'all' ? {} : { status: currentStatus };
const queryString = new URLSearchParams(filters).toString();
const response = await window.apiCall(`/workplace-visits/requests?${queryString}`, 'GET');
if (response && response.success) {
requests = response.data || [];
renderRequestTable();
updateStats();
}
} catch (error) {
console.error('출입 신청 목록 로드 오류:', error);
showToast('출입 신청 목록을 불러오는데 실패했습니다.', 'error');
}
}
/**
* 통계 업데이트
*/
async function updateStats() {
try {
const response = await window.apiCall('/workplace-visits/requests', 'GET');
if (response && response.success) {
const allRequests = response.data || [];
const stats = {
pending: allRequests.filter(r => r.status === 'pending').length,
approved: allRequests.filter(r => r.status === 'approved').length,
training_completed: allRequests.filter(r => r.status === 'training_completed').length,
rejected: allRequests.filter(r => r.status === 'rejected').length
};
document.getElementById('statPending').textContent = stats.pending;
document.getElementById('statApproved').textContent = stats.approved;
document.getElementById('statTrainingCompleted').textContent = stats.training_completed;
document.getElementById('statRejected').textContent = stats.rejected;
}
} catch (error) {
console.error('통계 업데이트 오류:', error);
}
}
/**
* 테이블 렌더링
*/
function renderRequestTable() {
const container = document.getElementById('requestTableContainer');
if (requests.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div style="font-size: 48px; margin-bottom: 16px;">📭</div>
<h3>출입 신청이 없습니다</h3>
<p>현재 ${getStatusText(currentStatus)} 상태의 신청이 없습니다.</p>
</div>
`;
return;
}
let html = `
<table class="request-table">
<thead>
<tr>
<th>신청일</th>
<th>신청자</th>
<th>방문자</th>
<th>인원</th>
<th>방문 작업장</th>
<th>방문 일시</th>
<th>목적</th>
<th>상태</th>
<th>작업</th>
</tr>
</thead>
<tbody>
`;
requests.forEach(req => {
const statusText = {
'pending': '승인 대기',
'approved': '승인됨',
'rejected': '반려됨',
'training_completed': '교육 완료'
}[req.status] || req.status;
html += `
<tr>
<td>${new Date(req.created_at).toLocaleDateString()}</td>
<td>${req.requester_full_name || req.requester_name}</td>
<td>${req.visitor_company}</td>
<td>${req.visitor_count}명</td>
<td>${req.category_name} - ${req.workplace_name}</td>
<td>${req.visit_date} ${req.visit_time}</td>
<td>${req.purpose_name}</td>
<td><span class="status-badge ${req.status}">${statusText}</span></td>
<td>
<div class="action-buttons">
<button class="btn btn-sm btn-secondary" onclick="viewDetail(${req.request_id})">상세</button>
${req.status === 'pending' ? `
<button class="btn btn-sm btn-primary" onclick="approveRequest(${req.request_id})">승인</button>
<button class="btn btn-sm btn-danger" onclick="openRejectModal(${req.request_id})">반려</button>
` : ''}
${req.status === 'approved' ? `
<button class="btn btn-sm btn-primary" onclick="startTraining(${req.request_id})">교육 진행</button>
` : ''}
</div>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
container.innerHTML = html;
}
/**
* 상태 텍스트 변환
*/
function getStatusText(status) {
const map = {
'pending': '승인 대기',
'approved': '승인 완료',
'rejected': '반려',
'training_completed': '교육 완료',
'all': '전체'
};
return map[status] || status;
}
// ==================== 탭 전환 ====================
/**
* 탭 전환
*/
async function switchTab(status) {
currentStatus = status;
// 탭 활성화 상태 변경
document.querySelectorAll('.status-tab').forEach(tab => {
if (tab.dataset.status === status) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
await loadRequests();
}
// ==================== 상세보기 ====================
/**
* 상세보기 모달 열기
*/
async function viewDetail(requestId) {
try {
const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET');
if (response && response.success) {
const req = response.data;
const statusText = {
'pending': '승인 대기',
'approved': '승인됨',
'rejected': '반려됨',
'training_completed': '교육 완료'
}[req.status] || req.status;
let html = `
<div class="detail-grid">
<div class="detail-label">신청 번호</div>
<div class="detail-value">#${req.request_id}</div>
<div class="detail-label">신청일</div>
<div class="detail-value">${new Date(req.created_at).toLocaleString()}</div>
<div class="detail-label">신청자</div>
<div class="detail-value">${req.requester_full_name || req.requester_name}</div>
<div class="detail-label">방문자 소속</div>
<div class="detail-value">${req.visitor_company}</div>
<div class="detail-label">방문 인원</div>
<div class="detail-value">${req.visitor_count}명</div>
<div class="detail-label">방문 구역</div>
<div class="detail-value">${req.category_name}</div>
<div class="detail-label">방문 작업장</div>
<div class="detail-value">${req.workplace_name}</div>
<div class="detail-label">방문 날짜</div>
<div class="detail-value">${req.visit_date}</div>
<div class="detail-label">방문 시간</div>
<div class="detail-value">${req.visit_time}</div>
<div class="detail-label">방문 목적</div>
<div class="detail-value">${req.purpose_name}</div>
<div class="detail-label">상태</div>
<div class="detail-value"><span class="status-badge ${req.status}">${statusText}</span></div>
</div>
`;
if (req.notes) {
html += `
<div style="margin-top: 16px; padding: 12px; background: var(--gray-50); border-radius: var(--radius-md);">
<strong>비고:</strong><br>
${req.notes}
</div>
`;
}
if (req.rejection_reason) {
html += `
<div style="margin-top: 16px; padding: 12px; background: var(--red-50); border-radius: var(--radius-md); color: var(--red-700);">
<strong>반려 사유:</strong><br>
${req.rejection_reason}
</div>
`;
}
if (req.approved_by) {
html += `
<div style="margin-top: 16px; padding: 12px; background: var(--blue-50); border-radius: var(--radius-md);">
<strong>처리 정보:</strong><br>
처리자: ${req.approver_name || 'Unknown'}<br>
처리 시간: ${new Date(req.approved_at).toLocaleString()}
</div>
`;
}
document.getElementById('detailContent').innerHTML = html;
document.getElementById('detailModal').style.display = 'flex';
}
} catch (error) {
console.error('상세 정보 로드 오류:', error);
showToast('상세 정보를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 상세보기 모달 닫기
*/
function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none';
}
// ==================== 승인/반려 ====================
/**
* 승인 처리
*/
async function approveRequest(requestId) {
if (!confirm('이 출입 신청을 승인하시겠습니까?')) {
return;
}
try {
const response = await window.apiCall(`/workplace-visits/requests/${requestId}/approve`, 'PUT');
if (response && response.success) {
showToast('출입 신청이 승인되었습니다.', 'success');
await loadRequests();
updateStats();
} else {
throw new Error(response?.message || '승인 실패');
}
} catch (error) {
console.error('승인 처리 오류:', error);
showToast(error.message || '승인 처리 중 오류가 발생했습니다.', 'error');
}
}
/**
* 반려 모달 열기
*/
function openRejectModal(requestId) {
currentRejectRequestId = requestId;
document.getElementById('rejectionReason').value = '';
document.getElementById('rejectModal').style.display = 'flex';
}
/**
* 반려 모달 닫기
*/
function closeRejectModal() {
currentRejectRequestId = null;
document.getElementById('rejectModal').style.display = 'none';
}
/**
* 반려 확정
*/
async function confirmReject() {
const reason = document.getElementById('rejectionReason').value.trim();
if (!reason) {
showToast('반려 사유를 입력해주세요.', 'warning');
return;
}
try {
const response = await window.apiCall(
`/workplace-visits/requests/${currentRejectRequestId}/reject`,
'PUT',
{ rejection_reason: reason }
);
if (response && response.success) {
showToast('출입 신청이 반려되었습니다.', 'success');
closeRejectModal();
await loadRequests();
updateStats();
} else {
throw new Error(response?.message || '반려 실패');
}
} catch (error) {
console.error('반려 처리 오류:', error);
showToast(error.message || '반려 처리 중 오류가 발생했습니다.', 'error');
}
}
// ==================== 안전교육 진행 ====================
/**
* 안전교육 진행 페이지로 이동
*/
function startTraining(requestId) {
window.location.href = `/pages/safety/training-conduct.html?request_id=${requestId}`;
}
// 전역 함수로 노출
window.switchTab = switchTab;
window.viewDetail = viewDetail;
window.closeDetailModal = closeDetailModal;
window.approveRequest = approveRequest;
window.openRejectModal = openRejectModal;
window.closeRejectModal = closeRejectModal;
window.confirmReject = confirmReject;
window.startTraining = startTraining;

View File

@@ -1,222 +0,0 @@
/**
* 안전신고 현황 페이지 JavaScript
* category_type=safety 고정 필터
*/
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
const CATEGORY_TYPE = 'safety';
// 상태 한글 변환
const STATUS_LABELS = {
reported: '신고',
received: '접수',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
// DOM 요소
let issueList;
let filterStatus, filterStartDate, filterEndDate;
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
issueList = document.getElementById('issueList');
filterStatus = document.getElementById('filterStatus');
filterStartDate = document.getElementById('filterStartDate');
filterEndDate = document.getElementById('filterEndDate');
// 필터 이벤트 리스너
filterStatus.addEventListener('change', loadIssues);
filterStartDate.addEventListener('change', loadIssues);
filterEndDate.addEventListener('change', loadIssues);
// 데이터 로드
await Promise.all([loadStats(), loadIssues()]);
});
/**
* 통계 로드 (안전만)
*/
async function loadStats() {
try {
const response = await fetch(`${API_BASE}/work-issues/stats/summary?category_type=${CATEGORY_TYPE}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('sso_token')}` }
});
if (!response.ok) {
document.getElementById('statsGrid').style.display = 'none';
return;
}
const data = await response.json();
if (data.success && data.data) {
document.getElementById('statReported').textContent = data.data.reported || 0;
document.getElementById('statReceived').textContent = data.data.received || 0;
document.getElementById('statProgress').textContent = data.data.in_progress || 0;
document.getElementById('statCompleted').textContent = data.data.completed || 0;
}
} catch (error) {
console.error('통계 로드 실패:', error);
document.getElementById('statsGrid').style.display = 'none';
}
}
/**
* 안전신고 목록 로드
*/
async function loadIssues() {
try {
// 필터 파라미터 구성 (category_type 고정)
const params = new URLSearchParams();
params.append('category_type', CATEGORY_TYPE);
if (filterStatus.value) params.append('status', filterStatus.value);
if (filterStartDate.value) params.append('start_date', filterStartDate.value);
if (filterEndDate.value) params.append('end_date', filterEndDate.value);
const response = await fetch(`${API_BASE}/work-issues?${params.toString()}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('sso_token')}` }
});
if (!response.ok) throw new Error('목록 조회 실패');
const data = await response.json();
if (data.success) {
renderIssues(data.data || []);
}
} catch (error) {
console.error('안전신고 목록 로드 실패:', error);
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">목록을 불러올 수 없습니다</div>
<p>잠시 후 다시 시도해주세요.</p>
</div>
`;
}
}
/**
* 안전신고 목록 렌더링
*/
function renderIssues(issues) {
if (issues.length === 0) {
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">등록된 안전 신고가 없습니다</div>
<p>새로운 안전 문제를 신고하려면 '안전 신고' 버튼을 클릭하세요.</p>
</div>
`;
return;
}
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
issueList.innerHTML = issues.map(issue => {
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// 위치 정보 (escaped)
let location = escapeHtml(issue.custom_location || '');
if (issue.factory_name) {
location = escapeHtml(issue.factory_name);
if (issue.workplace_name) {
location += ` - ${escapeHtml(issue.workplace_name)}`;
}
}
// 신고 제목 (항목명 또는 카테고리명)
const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '안전 신고');
const categoryName = escapeHtml(issue.issue_category_name || '안전');
// 사진 목록
const photos = [
issue.photo_path1,
issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(Boolean);
// 안전한 값들
const safeReportId = parseInt(issue.report_id) || 0;
const validStatuses = ['reported', 'received', 'in_progress', 'completed', 'closed'];
const safeStatus = validStatuses.includes(issue.status) ? issue.status : 'reported';
const reporterName = escapeHtml(issue.reporter_full_name || issue.reporter_name || '-');
const assignedName = issue.assigned_full_name ? escapeHtml(issue.assigned_full_name) : '';
return `
<div class="issue-card" onclick="viewIssue(${safeReportId})">
<div class="issue-header">
<span class="issue-id">#${safeReportId}</span>
<span class="issue-status ${safeStatus}">${STATUS_LABELS[issue.status] || escapeHtml(issue.status || '-')}</span>
</div>
<div class="issue-title">
<span class="issue-category-badge">${categoryName}</span>
${title}
</div>
<div class="issue-meta">
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
${reporterName}
</span>
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
${reportDate}
</span>
${location ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
${location}
</span>
` : ''}
${assignedName ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
담당: ${assignedName}
</span>
` : ''}
</div>
${photos.length > 0 ? `
<div class="issue-photos">
${photos.slice(0, 3).map(p => `
<img src="${baseUrl}${encodeURI(p)}" alt="신고 사진" loading="lazy">
`).join('')}
${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''}
</div>
` : ''}
</div>
`;
}).join('');
}
/**
* 상세 보기
*/
function viewIssue(reportId) {
window.location.href = `/pages/safety/issue-detail.html?id=${reportId}&from=safety`;
}

View File

@@ -1,474 +0,0 @@
// 안전교육 진행 페이지 JavaScript
let requestId = null;
let requestData = null;
let canvas = null;
let ctx = null;
let isDrawing = false;
let hasSignature = false;
let savedSignatures = []; // 저장된 서명 목록
// showToast → api-base.js 전역 사용
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async () => {
// URL 파라미터에서 request_id 가져오기
const urlParams = new URLSearchParams(window.location.search);
requestId = urlParams.get('request_id');
if (!requestId) {
showToast('출입 신청 ID가 없습니다.', 'error');
setTimeout(() => {
window.location.href = '/pages/safety/management.html';
}, 2000);
return;
}
// 서명 캔버스 초기화
initSignatureCanvas();
// 현재 날짜 표시
const today = new Date().toLocaleDateString('ko-KR');
document.getElementById('signatureDate').textContent = today;
// 출입 신청 정보 로드
await loadRequestInfo();
});
// ==================== 출입 신청 정보 로드 ====================
/**
* 출입 신청 정보 로드
*/
async function loadRequestInfo() {
try {
const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET');
if (response && response.success) {
requestData = response.data;
// 상태 확인 - 승인됨 상태만 진행 가능
if (requestData.status !== 'approved') {
showToast('이미 처리되었거나 승인되지 않은 신청입니다.', 'error');
setTimeout(() => {
window.location.href = '/pages/safety/management.html';
}, 2000);
return;
}
renderRequestInfo();
} else {
throw new Error(response?.message || '정보를 불러올 수 없습니다.');
}
} catch (error) {
console.error('출입 신청 정보 로드 오류:', error);
showToast('출입 신청 정보를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 출입 신청 정보 렌더링
*/
function renderRequestInfo() {
const container = document.getElementById('requestInfo');
// 날짜 포맷 변환
const visitDate = new Date(requestData.visit_date);
const formattedDate = visitDate.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short'
});
const html = `
<div class="info-item">
<div class="info-label">신청 번호</div>
<div class="info-value">#${requestData.request_id}</div>
</div>
<div class="info-item">
<div class="info-label">신청자</div>
<div class="info-value">${requestData.requester_full_name || requestData.requester_name}</div>
</div>
<div class="info-item">
<div class="info-label">방문자 소속</div>
<div class="info-value">${requestData.visitor_company}</div>
</div>
<div class="info-item">
<div class="info-label">방문 인원</div>
<div class="info-value">${requestData.visitor_count}명</div>
</div>
<div class="info-item">
<div class="info-label">방문 작업장</div>
<div class="info-value">${requestData.category_name} - ${requestData.workplace_name}</div>
</div>
<div class="info-item">
<div class="info-label">방문 일시</div>
<div class="info-value">${formattedDate} ${requestData.visit_time}</div>
</div>
<div class="info-item">
<div class="info-label">방문 목적</div>
<div class="info-value">${requestData.purpose_name}</div>
</div>
`;
container.innerHTML = html;
}
// ==================== 서명 캔버스 ====================
/**
* 서명 캔버스 초기화
*/
function initSignatureCanvas() {
canvas = document.getElementById('signatureCanvas');
ctx = canvas.getContext('2d');
// 캔버스 크기 설정
const container = canvas.parentElement;
canvas.width = container.clientWidth - 4; // border 제외
canvas.height = 300;
// 그리기 설정
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 마우스 이벤트
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseout', stopDrawing);
// 터치 이벤트 (모바일, Apple Pencil)
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', stopDrawing);
canvas.addEventListener('touchcancel', stopDrawing);
// Pointer Events (Apple Pencil 최적화)
if (window.PointerEvent) {
canvas.addEventListener('pointerdown', handlePointerDown);
canvas.addEventListener('pointermove', handlePointerMove);
canvas.addEventListener('pointerup', stopDrawing);
canvas.addEventListener('pointercancel', stopDrawing);
}
}
/**
* 그리기 시작 (마우스)
*/
function startDrawing(e) {
isDrawing = true;
hasSignature = true;
document.getElementById('signaturePlaceholder').style.display = 'none';
const rect = canvas.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
}
/**
* 그리기 (마우스)
*/
function draw(e) {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
ctx.stroke();
}
/**
* 그리기 중지
*/
function stopDrawing() {
isDrawing = false;
ctx.beginPath();
}
/**
* 터치 시작 처리
*/
function handleTouchStart(e) {
e.preventDefault();
isDrawing = true;
hasSignature = true;
document.getElementById('signaturePlaceholder').style.display = 'none';
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(touch.clientX - rect.left, touch.clientY - rect.top);
}
/**
* 터치 이동 처리
*/
function handleTouchMove(e) {
if (!isDrawing) return;
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
ctx.lineTo(touch.clientX - rect.left, touch.clientY - rect.top);
ctx.stroke();
}
/**
* Pointer 시작 처리 (Apple Pencil)
*/
function handlePointerDown(e) {
isDrawing = true;
hasSignature = true;
document.getElementById('signaturePlaceholder').style.display = 'none';
const rect = canvas.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
}
/**
* Pointer 이동 처리 (Apple Pencil)
*/
function handlePointerMove(e) {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
ctx.stroke();
}
/**
* 서명 지우기
*/
function clearSignature() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
hasSignature = false;
document.getElementById('signaturePlaceholder').style.display = 'block';
}
/**
* 서명을 Base64로 변환
*/
function getSignatureBase64() {
if (!hasSignature) {
return null;
}
return canvas.toDataURL('image/png');
}
/**
* 현재 서명 저장
*/
function saveSignature() {
if (!hasSignature) {
showToast('서명이 없습니다. 이름과 서명을 작성해주세요.', 'warning');
return;
}
const signatureImage = getSignatureBase64();
const now = new Date();
savedSignatures.push({
id: Date.now(),
image: signatureImage,
timestamp: now.toLocaleString('ko-KR')
});
// 서명 카운트 업데이트
document.getElementById('signatureCount').textContent = savedSignatures.length;
// 캔버스 초기화
clearSignature();
// 저장된 서명 목록 렌더링
renderSavedSignatures();
// 교육 완료 버튼 활성화
updateCompleteButton();
showToast('서명이 저장되었습니다.', 'success');
}
/**
* 저장된 서명 목록 렌더링
*/
function renderSavedSignatures() {
const container = document.getElementById('savedSignatures');
if (savedSignatures.length === 0) {
container.innerHTML = '';
return;
}
let html = '<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--gray-700);">저장된 서명 목록</h3>';
savedSignatures.forEach((sig, index) => {
html += `
<div class="saved-signature-card">
<img src="${sig.image}" alt="서명 ${index + 1}">
<div class="saved-signature-info">
<div class="saved-signature-number">방문자 ${index + 1}</div>
<div class="saved-signature-date">저장 시간: ${sig.timestamp}</div>
</div>
<div class="saved-signature-actions">
<button type="button" class="btn btn-sm btn-danger" onclick="deleteSignature(${sig.id})">
삭제
</button>
</div>
</div>
`;
});
container.innerHTML = html;
}
/**
* 서명 삭제
*/
function deleteSignature(signatureId) {
if (!confirm('이 서명을 삭제하시겠습니까?')) {
return;
}
savedSignatures = savedSignatures.filter(sig => sig.id !== signatureId);
// 서명 카운트 업데이트
document.getElementById('signatureCount').textContent = savedSignatures.length;
// 목록 다시 렌더링
renderSavedSignatures();
// 교육 완료 버튼 상태 업데이트
updateCompleteButton();
showToast('서명이 삭제되었습니다.', 'success');
}
/**
* 교육 완료 버튼 활성화/비활성화
*/
function updateCompleteButton() {
const completeBtn = document.getElementById('completeBtn');
// 체크리스트와 서명이 모두 있어야 활성화
const checkboxes = document.querySelectorAll('input[name="safety-check"]');
const checkedItems = Array.from(checkboxes).filter(cb => cb.checked);
const allChecked = checkedItems.length === checkboxes.length;
const hasSignatures = savedSignatures.length > 0;
completeBtn.disabled = !(allChecked && hasSignatures);
}
// ==================== 교육 완료 처리 ====================
/**
* 교육 완료 처리
*/
async function completeTraining() {
// 체크리스트 검증
const checkboxes = document.querySelectorAll('input[name="safety-check"]');
const checkedItems = Array.from(checkboxes).filter(cb => cb.checked);
if (checkedItems.length !== checkboxes.length) {
showToast('모든 안전교육 항목을 체크해주세요.', 'warning');
return;
}
// 서명 검증
if (savedSignatures.length === 0) {
showToast('최소 1명 이상의 서명이 필요합니다.', 'warning');
return;
}
// 확인
if (!confirm(`${savedSignatures.length}명의 방문자 안전교육을 완료하시겠습니까?\n완료 후에는 수정할 수 없습니다.`)) {
return;
}
try {
// 교육 항목 수집
const trainingItems = checkedItems.map(cb => cb.value).join(', ');
// API 호출
const userData = localStorage.getItem('sso_user');
const currentUser = userData ? JSON.parse(userData) : null;
if (!currentUser) {
showToast('로그인 정보를 찾을 수 없습니다.', 'error');
return;
}
// 현재 시간
const now = new Date();
const currentTime = now.toTimeString().split(' ')[0]; // HH:MM:SS
const trainingDate = now.toISOString().split('T')[0]; // YYYY-MM-DD
// 각 서명에 대해 개별적으로 API 호출
let successCount = 0;
for (let i = 0; i < savedSignatures.length; i++) {
const sig = savedSignatures[i];
const payload = {
request_id: requestId,
conducted_by: currentUser.user_id,
training_date: trainingDate,
training_start_time: currentTime,
training_end_time: currentTime,
training_items: trainingItems,
visitor_name: `방문자 ${i + 1}`, // 순번으로 구분
signature_image: sig.image,
notes: `교육 완료 - ${checkedItems.length}개 항목 (${i + 1}/${savedSignatures.length})`
};
const response = await window.apiCall(
'/workplace-visits/training',
'POST',
payload
);
if (response && response.success) {
successCount++;
} else {
console.error(`서명 ${i + 1} 저장 실패:`, response);
}
}
if (successCount === savedSignatures.length) {
showToast(`${successCount}명의 안전교육이 완료되었습니다.`, 'success');
setTimeout(() => {
window.location.href = '/pages/safety/management.html';
}, 1500);
} else if (successCount > 0) {
showToast(`${successCount}/${savedSignatures.length}명의 교육만 저장되었습니다.`, 'warning');
} else {
throw new Error('교육 완료 처리 실패');
}
} catch (error) {
console.error('교육 완료 처리 오류:', error);
showToast(error.message || '교육 완료 처리 중 오류가 발생했습니다.', 'error');
}
}
/**
* 뒤로 가기
*/
function goBack() {
if (hasSignature || document.querySelector('input[name="safety-check"]:checked')) {
if (!confirm('작성 중인 내용이 있습니다. 정말 나가시겠습니까?')) {
return;
}
}
window.location.href = '/pages/safety/management.html';
}
// 전역 함수로 노출
window.clearSignature = clearSignature;
window.saveSignature = saveSignature;
window.deleteSignature = deleteSignature;
window.updateCompleteButton = updateCompleteButton;
window.completeTraining = completeTraining;
window.goBack = goBack;

View File

@@ -1,443 +0,0 @@
// 출입 신청 페이지 JavaScript
let categories = [];
let workplaces = [];
let mapRegions = [];
let visitPurposes = [];
let selectedWorkplace = null;
let selectedCategory = null;
let canvas = null;
let ctx = null;
let layoutImage = null;
// showToast, createToastContainer → api-base.js 전역 사용
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async () => {
// 오늘 날짜 기본값 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('visitDate').value = today;
document.getElementById('visitDate').min = today;
// 현재 시간 + 1시간 기본값 설정
const now = new Date();
now.setHours(now.getHours() + 1);
const timeString = now.toTimeString().slice(0, 5);
document.getElementById('visitTime').value = timeString;
// 데이터 로드
await loadCategories();
await loadVisitPurposes();
await loadMyRequests();
// 폼 제출 이벤트
document.getElementById('visitRequestForm').addEventListener('submit', handleSubmit);
// 캔버스 초기화
canvas = document.getElementById('workplaceMapCanvas');
ctx = canvas.getContext('2d');
});
// ==================== 데이터 로드 ====================
/**
* 카테고리(공장) 목록 로드
*/
async function loadCategories() {
try {
const response = await window.apiCall('/workplaces/categories', 'GET');
if (response && response.success) {
categories = response.data || [];
const categorySelect = document.getElementById('categorySelect');
categorySelect.innerHTML = '<option value="">구역을 선택하세요</option>';
categories.forEach(cat => {
if (cat.is_active) {
const option = document.createElement('option');
option.value = cat.category_id;
option.textContent = cat.category_name;
categorySelect.appendChild(option);
}
});
}
} catch (error) {
console.error('카테고리 로드 오류:', error);
window.showToast('카테고리 로드 중 오류가 발생했습니다.', 'error');
}
}
/**
* 방문 목적 목록 로드
*/
async function loadVisitPurposes() {
try {
const response = await window.apiCall('/workplace-visits/purposes/active', 'GET');
if (response && response.success) {
visitPurposes = response.data || [];
const purposeSelect = document.getElementById('visitPurpose');
purposeSelect.innerHTML = '<option value="">선택하세요</option>';
visitPurposes.forEach(purpose => {
const option = document.createElement('option');
option.value = purpose.purpose_id;
option.textContent = purpose.purpose_name;
purposeSelect.appendChild(option);
});
}
} catch (error) {
console.error('방문 목적 로드 오류:', error);
window.showToast('방문 목적 로드 중 오류가 발생했습니다.', 'error');
}
}
/**
* 내 출입 신청 목록 로드
*/
async function loadMyRequests() {
try {
// localStorage에서 사용자 정보 가져오기
const userData = localStorage.getItem('sso_user');
const currentUser = userData ? JSON.parse(userData) : null;
if (!currentUser || !currentUser.user_id) {
console.log('사용자 정보 없음');
return;
}
const response = await window.apiCall(`/workplace-visits/requests?requester_id=${currentUser.user_id}`, 'GET');
if (response && response.success) {
const requests = response.data || [];
renderMyRequests(requests);
}
} catch (error) {
console.error('내 신청 목록 로드 오류:', error);
}
}
/**
* 내 신청 목록 렌더링
*/
function renderMyRequests(requests) {
const listDiv = document.getElementById('myRequestsList');
if (requests.length === 0) {
listDiv.innerHTML = '<p style="text-align: center; color: var(--gray-500); padding: 32px;">신청 내역이 없습니다</p>';
return;
}
let html = '';
requests.forEach(req => {
const statusText = {
'pending': '승인 대기',
'approved': '승인됨',
'rejected': '반려됨',
'training_completed': '교육 완료'
}[req.status] || req.status;
html += `
<div class="request-card">
<div class="request-card-header">
<h3 style="margin: 0; font-size: var(--text-lg);">${req.visitor_company} (${req.visitor_count}명)</h3>
<span class="request-status ${req.status}">${statusText}</span>
</div>
<div class="request-info">
<div class="info-item">
<span class="info-label">방문 작업장</span>
<span class="info-value">${req.category_name} - ${req.workplace_name}</span>
</div>
<div class="info-item">
<span class="info-label">방문 일시</span>
<span class="info-value">${req.visit_date} ${req.visit_time}</span>
</div>
<div class="info-item">
<span class="info-label">방문 목적</span>
<span class="info-value">${req.purpose_name}</span>
</div>
<div class="info-item">
<span class="info-label">신청일</span>
<span class="info-value">${new Date(req.created_at).toLocaleDateString()}</span>
</div>
</div>
${req.rejection_reason ? `<p style="margin-top: 12px; padding: 12px; background: var(--red-50); color: var(--red-700); border-radius: var(--radius-md); font-size: var(--text-sm);"><strong>반려 사유:</strong> ${req.rejection_reason}</p>` : ''}
${req.notes ? `<p style="margin-top: 12px; color: var(--gray-600); font-size: var(--text-sm);"><strong>비고:</strong> ${req.notes}</p>` : ''}
</div>
`;
});
listDiv.innerHTML = html;
}
// ==================== 작업장 지도 모달 ====================
/**
* 지도 모달 열기
*/
function openMapModal() {
document.getElementById('mapModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
/**
* 지도 모달 닫기
*/
function closeMapModal() {
document.getElementById('mapModal').style.display = 'none';
document.body.style.overflow = '';
}
/**
* 작업장 지도 로드
*/
async function loadWorkplaceMap() {
const categoryId = document.getElementById('categorySelect').value;
if (!categoryId) {
document.getElementById('mapCanvasContainer').style.display = 'none';
return;
}
selectedCategory = categories.find(c => c.category_id == categoryId);
try {
// 작업장 목록 로드
const workplacesResponse = await window.apiCall(`/workplaces/categories/${categoryId}`, 'GET');
if (workplacesResponse && workplacesResponse.success) {
workplaces = workplacesResponse.data || [];
}
// 지도 영역 로드
const regionsResponse = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`, 'GET');
if (regionsResponse && regionsResponse.success) {
mapRegions = regionsResponse.data || [];
}
// 레이아웃 이미지가 있으면 표시
if (selectedCategory && selectedCategory.layout_image) {
// API_BASE_URL에서 /api 제거하고 이미지 경로 생성
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
? selectedCategory.layout_image
: `${baseUrl}${selectedCategory.layout_image}`;
console.log('이미지 URL:', fullImageUrl);
loadImageToCanvas(fullImageUrl);
document.getElementById('mapCanvasContainer').style.display = 'block';
} else {
window.showToast('선택한 구역에 레이아웃 지도가 없습니다.', 'warning');
document.getElementById('mapCanvasContainer').style.display = 'none';
}
} catch (error) {
console.error('작업장 지도 로드 오류:', error);
window.showToast('작업장 지도 로드 중 오류가 발생했습니다.', 'error');
}
}
/**
* 이미지를 캔버스에 로드
*/
function loadImageToCanvas(imagePath) {
const img = new Image();
// crossOrigin 제거 - 같은 도메인이므로 불필요
img.onload = function() {
// 캔버스 크기 설정
const maxWidth = 800;
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
// 이미지 그리기
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
layoutImage = img;
// 영역 표시
drawRegions();
// 클릭 이벤트 등록
canvas.onclick = handleCanvasClick;
};
img.onerror = function() {
window.showToast('지도 이미지를 불러올 수 없습니다.', 'error');
};
img.src = imagePath;
}
/**
* 지도 영역 그리기
*/
function drawRegions() {
mapRegions.forEach(region => {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
// 영역 박스
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
ctx.fillStyle = 'rgba(16, 185, 129, 0.1)';
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
// 작업장 이름
ctx.fillStyle = '#10b981';
ctx.font = 'bold 14px sans-serif';
ctx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
});
}
/**
* 캔버스 클릭 핸들러
*/
function handleCanvasClick(event) {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// 클릭한 위치의 영역 찾기
for (const region of mapRegions) {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
// 작업장 선택
selectWorkplace(region);
return;
}
}
window.showToast('작업장 영역을 클릭해주세요.', 'warning');
}
/**
* 작업장 선택
*/
function selectWorkplace(region) {
selectedWorkplace = {
workplace_id: region.workplace_id,
workplace_name: region.workplace_name,
category_id: selectedCategory.category_id,
category_name: selectedCategory.category_name
};
// 선택 표시
const selectionDiv = document.getElementById('workplaceSelection');
selectionDiv.classList.add('selected');
selectionDiv.innerHTML = `
<div class="icon">✅</div>
<div class="text">${selectedCategory.category_name} - ${region.workplace_name}</div>
`;
// 상세 정보 카드 표시
const infoDiv = document.getElementById('selectedWorkplaceInfo');
infoDiv.style.display = 'block';
infoDiv.innerHTML = `
<div class="workplace-info-card">
<div class="icon">📍</div>
<div class="details">
<div class="name">${region.workplace_name}</div>
<div class="category">${selectedCategory.category_name}</div>
</div>
<button type="button" class="btn btn-sm btn-secondary" onclick="clearWorkplaceSelection()">변경</button>
</div>
`;
// 모달 닫기
closeMapModal();
window.showToast(`${region.workplace_name} 작업장이 선택되었습니다.`, 'success');
}
/**
* 작업장 선택 초기화
*/
function clearWorkplaceSelection() {
selectedWorkplace = null;
const selectionDiv = document.getElementById('workplaceSelection');
selectionDiv.classList.remove('selected');
selectionDiv.innerHTML = `
<div class="icon">📍</div>
<div class="text">지도에서 작업장을 선택하세요</div>
`;
document.getElementById('selectedWorkplaceInfo').style.display = 'none';
}
// ==================== 폼 제출 ====================
/**
* 출입 신청 제출
*/
async function handleSubmit(event) {
event.preventDefault();
if (!selectedWorkplace) {
window.showToast('작업장을 선택해주세요.', 'warning');
openMapModal();
return;
}
const formData = {
visitor_company: document.getElementById('visitorCompany').value.trim(),
visitor_count: parseInt(document.getElementById('visitorCount').value),
category_id: selectedWorkplace.category_id,
workplace_id: selectedWorkplace.workplace_id,
visit_date: document.getElementById('visitDate').value,
visit_time: document.getElementById('visitTime').value,
purpose_id: parseInt(document.getElementById('visitPurpose').value),
notes: document.getElementById('notes').value.trim() || null
};
try {
const response = await window.apiCall('/workplace-visits/requests', 'POST', formData);
if (response && response.success) {
window.showToast('출입 신청 및 안전교육 신청이 완료되었습니다. 안전관리자의 승인을 기다려주세요.', 'success');
// 폼 초기화
resetForm();
// 내 신청 목록 새로고침
await loadMyRequests();
} else {
throw new Error(response?.message || '신청 실패');
}
} catch (error) {
console.error('출입 신청 오류:', error);
window.showToast(error.message || '출입 신청 중 오류가 발생했습니다.', 'error');
}
}
/**
* 폼 초기화
*/
function resetForm() {
document.getElementById('visitRequestForm').reset();
clearWorkplaceSelection();
// 오늘 날짜와 시간 다시 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('visitDate').value = today;
const now = new Date();
now.setHours(now.getHours() + 1);
const timeString = now.toTimeString().slice(0, 5);
document.getElementById('visitTime').value = timeString;
document.getElementById('visitorCount').value = 1;
}
// 전역 함수로 노출
window.openMapModal = openMapModal;
window.closeMapModal = closeMapModal;
window.loadWorkplaceMap = loadWorkplaceMap;
window.clearWorkplaceSelection = clearWorkplaceSelection;
window.resetForm = resetForm;

View File

@@ -1,582 +0,0 @@
// 작업자 관리 페이지 JavaScript (부서 기반)
// 전역 변수
let departments = [];
let currentDepartmentId = null;
let allWorkers = [];
let filteredWorkers = [];
let currentEditingWorker = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForApi();
await loadDepartments();
});
// waitForApi → api-base.js 전역 사용
// ============================================
// 부서 관련 함수
// ============================================
// 부서 목록 로드
async function loadDepartments() {
try {
const response = await window.apiCall('/departments');
const result = response;
if (result && result.success) {
departments = result.data;
renderDepartmentList();
updateParentDepartmentSelect();
} else if (Array.isArray(result)) {
departments = result;
renderDepartmentList();
updateParentDepartmentSelect();
}
} catch (error) {
console.error('부서 목록 로드 실패:', error);
showToast('부서 목록을 불러오는데 실패했습니다.', 'error');
}
}
// 부서 목록 렌더링
function renderDepartmentList() {
const container = document.getElementById('departmentList');
if (!container) return;
if (departments.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
등록된 부서가 없습니다.<br>
<button class="btn btn-primary btn-sm" style="margin-top: 1rem;" onclick="openDepartmentModal()">
첫 부서 등록하기
</button>
</div>
`;
return;
}
container.innerHTML = departments.map(dept => {
const safeDeptId = parseInt(dept.department_id) || 0;
const safeDeptName = escapeHtml(dept.department_name || '-');
const workerCount = parseInt(dept.worker_count) || 0;
return `
<div class="department-item ${currentDepartmentId === dept.department_id ? 'active' : ''}"
onclick="selectDepartment(${safeDeptId})">
<div class="department-info">
<span class="department-name">${safeDeptName}</span>
<span class="department-count">${workerCount}명</span>
</div>
<div class="department-actions" onclick="event.stopPropagation()">
<button class="btn-icon" onclick="editDepartment(${safeDeptId})" title="수정">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="btn-icon danger" onclick="confirmDeleteDepartment(${safeDeptId})" title="삭제">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
`;
}).join('');
}
// 부서 선택
async function selectDepartment(departmentId) {
currentDepartmentId = departmentId;
renderDepartmentList();
const dept = departments.find(d => d.department_id === departmentId);
document.getElementById('workerListTitle').textContent = `${dept.department_name} 작업자`;
document.getElementById('addWorkerBtn').style.display = 'inline-flex';
document.getElementById('workerToolbar').style.display = 'flex';
await loadWorkersByDepartment(departmentId);
}
// 상위 부서 선택 옵션 업데이트
function updateParentDepartmentSelect() {
const select = document.getElementById('parentDepartment');
if (!select) return;
const currentId = document.getElementById('departmentId')?.value;
select.innerHTML = '<option value="">없음 (최상위 부서)</option>' +
departments
.filter(d => d.department_id !== parseInt(currentId))
.map(d => {
const safeDeptId = parseInt(d.department_id) || 0;
return `<option value="${safeDeptId}">${escapeHtml(d.department_name || '-')}</option>`;
})
.join('');
}
// 부서 모달 열기
function openDepartmentModal(departmentId = null) {
const modal = document.getElementById('departmentModal');
const title = document.getElementById('departmentModalTitle');
const form = document.getElementById('departmentForm');
const deleteBtn = document.getElementById('deleteDeptBtn');
updateParentDepartmentSelect();
if (departmentId) {
const dept = departments.find(d => d.department_id === departmentId);
title.textContent = '부서 수정';
deleteBtn.style.display = 'inline-flex';
document.getElementById('departmentId').value = dept.department_id;
document.getElementById('departmentName').value = dept.department_name;
document.getElementById('parentDepartment').value = dept.parent_id || '';
document.getElementById('departmentDescription').value = dept.description || '';
document.getElementById('displayOrder').value = dept.display_order || 0;
document.getElementById('isActiveDept').checked = dept.is_active !== 0 && dept.is_active !== false;
} else {
title.textContent = '새 부서 등록';
deleteBtn.style.display = 'none';
form.reset();
document.getElementById('departmentId').value = '';
document.getElementById('isActiveDept').checked = true;
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
setTimeout(() => {
document.getElementById('departmentName').focus();
}, 100);
}
// 부서 모달 닫기
function closeDepartmentModal() {
const modal = document.getElementById('departmentModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
}
}
// 부서 저장
async function saveDepartment() {
const departmentId = document.getElementById('departmentId').value;
const data = {
department_name: document.getElementById('departmentName').value.trim(),
parent_id: document.getElementById('parentDepartment').value || null,
description: document.getElementById('departmentDescription').value.trim(),
display_order: parseInt(document.getElementById('displayOrder').value) || 0,
is_active: document.getElementById('isActiveDept').checked
};
if (!data.department_name) {
showToast('부서명은 필수 입력 항목입니다.', 'error');
return;
}
try {
const url = departmentId ? `/departments/${departmentId}` : '/departments';
const method = departmentId ? 'PUT' : 'POST';
const response = await window.apiCall(url, method, data);
if (response && response.success) {
showToast(response.message || '부서가 저장되었습니다.', 'success');
closeDepartmentModal();
await loadDepartments();
} else {
throw new Error(response?.error || '저장 실패');
}
} catch (error) {
console.error('부서 저장 실패:', error);
showToast('부서 저장에 실패했습니다.', 'error');
}
}
// 부서 수정
function editDepartment(departmentId) {
openDepartmentModal(departmentId);
}
// 부서 삭제 확인
function confirmDeleteDepartment(departmentId) {
const dept = departments.find(d => d.department_id === departmentId);
if (!dept) return;
const workerCount = dept.worker_count || 0;
let message = `"${dept.department_name}" 부서를 삭제하시겠습니까?`;
if (workerCount > 0) {
message += `\n\n⚠️ 이 부서에는 ${workerCount}명의 작업자가 있습니다.\n삭제하면 작업자들의 부서 정보가 제거됩니다.`;
}
if (confirm(message)) {
deleteDepartment(departmentId);
}
}
// 부서 삭제
async function deleteDepartment(departmentId = null) {
const id = departmentId || document.getElementById('departmentId').value;
if (!id) return;
try {
const response = await window.apiCall(`/departments/${id}`, 'DELETE');
if (response && response.success) {
showToast('부서가 삭제되었습니다.', 'success');
closeDepartmentModal();
if (currentDepartmentId === parseInt(id)) {
currentDepartmentId = null;
document.getElementById('workerListTitle').textContent = '부서를 선택하세요';
document.getElementById('addWorkerBtn').style.display = 'none';
document.getElementById('workerToolbar').style.display = 'none';
document.getElementById('workerList').innerHTML = `
<div class="empty-state">
<h4>부서를 선택해주세요</h4>
<p>왼쪽에서 부서를 선택하면 해당 부서의 작업자가 표시됩니다.</p>
</div>
`;
}
await loadDepartments();
} else {
throw new Error(response?.error || '삭제 실패');
}
} catch (error) {
console.error('부서 삭제 실패:', error);
showToast(error.message || '부서 삭제에 실패했습니다.', 'error');
}
}
// ============================================
// 작업자 관련 함수
// ============================================
// 부서별 작업자 로드
async function loadWorkersByDepartment(departmentId) {
try {
const response = await window.apiCall(`/departments/${departmentId}/workers`);
if (response && response.success) {
allWorkers = response.data;
filteredWorkers = [...allWorkers];
renderWorkerList();
} else if (Array.isArray(response)) {
allWorkers = response;
filteredWorkers = [...allWorkers];
renderWorkerList();
}
} catch (error) {
console.error('작업자 목록 로드 실패:', error);
showToast('작업자 목록을 불러오는데 실패했습니다.', 'error');
}
}
// 작업자 목록 렌더링
function renderWorkerList() {
const container = document.getElementById('workerList');
if (!container) return;
if (filteredWorkers.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h4>작업자가 없습니다</h4>
<p>"+ 작업자 추가" 버튼을 눌러 작업자를 등록하세요.</p>
</div>
`;
return;
}
const tableHtml = `
<table class="workers-table">
<thead>
<tr>
<th>이름</th>
<th>직책</th>
<th>상태</th>
<th>입사일</th>
<th>계정</th>
<th style="width: 100px;">관리</th>
</tr>
</thead>
<tbody>
${filteredWorkers.map(worker => {
const jobTypeMap = {
'worker': '작업자',
'leader': '그룹장',
'admin': '관리자'
};
const safeJobType = ['worker', 'leader', 'admin'].includes(worker.job_type) ? worker.job_type : '';
const jobType = jobTypeMap[safeJobType] || escapeHtml(worker.job_type || '-');
const isInactive = worker.status === 'inactive';
const isResigned = worker.employment_status === 'resigned';
const hasAccount = worker.user_id !== null && worker.user_id !== undefined;
let statusClass = 'active';
let statusText = '현장직';
if (isResigned) {
statusClass = 'resigned';
statusText = '퇴사';
} else if (isInactive) {
statusClass = 'inactive';
statusText = '사무직';
}
const safeWorkerId = parseInt(worker.user_id) || 0;
const safeWorkerName = escapeHtml(worker.worker_name || '');
const firstChar = safeWorkerName ? safeWorkerName.charAt(0) : '?';
return `
<tr style="${isResigned ? 'opacity: 0.6;' : ''}">
<td>
<div class="worker-name-cell">
<div class="worker-avatar">${firstChar}</div>
<span style="font-weight: 500;">${safeWorkerName}</span>
</div>
</td>
<td>${jobType}</td>
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
<td>${worker.join_date ? formatDate(worker.join_date) : '-'}</td>
<td>
<span class="account-badge ${hasAccount ? 'has-account' : 'no-account'}">
${hasAccount ? '🔐 연동' : '⚪ 없음'}
</span>
</td>
<td>
<div style="display: flex; gap: 0.25rem; justify-content: center;">
<button class="btn-icon" onclick="editWorker(${safeWorkerId})" title="수정"></button>
<button class="btn-icon danger" onclick="confirmDeleteWorker(${safeWorkerId})" title="삭제">🗑</button>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHtml;
}
// 작업자 필터링
function filterWorkers() {
const searchTerm = document.getElementById('workerSearch')?.value.toLowerCase().trim() || '';
const statusValue = document.getElementById('statusFilter')?.value || '';
filteredWorkers = allWorkers.filter(worker => {
// 검색 필터
const matchesSearch = !searchTerm ||
worker.worker_name.toLowerCase().includes(searchTerm) ||
(worker.job_type && worker.job_type.toLowerCase().includes(searchTerm));
// 상태 필터
let matchesStatus = true;
if (statusValue === 'active') {
matchesStatus = worker.status !== 'inactive' && worker.employment_status !== 'resigned';
} else if (statusValue === 'inactive') {
matchesStatus = worker.status === 'inactive';
} else if (statusValue === 'resigned') {
matchesStatus = worker.employment_status === 'resigned';
}
return matchesSearch && matchesStatus;
});
renderWorkerList();
}
// 작업자 모달 열기
function openWorkerModal(workerId = null) {
const modal = document.getElementById('workerModal');
const title = document.getElementById('workerModalTitle');
const deleteBtn = document.getElementById('deleteWorkerBtn');
if (!currentDepartmentId) {
showToast('먼저 부서를 선택해주세요.', 'error');
return;
}
if (workerId) {
const worker = allWorkers.find(w => w.user_id === workerId);
if (!worker) {
showToast('작업자를 찾을 수 없습니다.', 'error');
return;
}
currentEditingWorker = worker;
title.textContent = '작업자 정보 수정';
deleteBtn.style.display = 'inline-flex';
document.getElementById('workerId').value = worker.user_id;
document.getElementById('workerName').value = worker.worker_name || '';
document.getElementById('jobType').value = worker.job_type || 'worker';
document.getElementById('joinDate').value = worker.join_date ? worker.join_date.split('T')[0] : '';
document.getElementById('salary').value = worker.salary || '';
document.getElementById('annualLeave').value = worker.annual_leave || 0;
document.getElementById('isActiveWorker').checked = worker.status !== 'inactive';
document.getElementById('createAccount').checked = worker.user_id !== null && worker.user_id !== undefined;
document.getElementById('isResigned').checked = worker.employment_status === 'resigned';
} else {
currentEditingWorker = null;
title.textContent = '새 작업자 등록';
deleteBtn.style.display = 'none';
document.getElementById('workerForm').reset();
document.getElementById('workerId').value = '';
document.getElementById('isActiveWorker').checked = true;
document.getElementById('createAccount').checked = false;
document.getElementById('isResigned').checked = false;
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
setTimeout(() => {
document.getElementById('workerName').focus();
}, 100);
}
// 작업자 모달 닫기
function closeWorkerModal() {
const modal = document.getElementById('workerModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
currentEditingWorker = null;
}
}
// 작업자 편집
function editWorker(workerId) {
openWorkerModal(workerId);
}
// 작업자 저장
async function saveWorker() {
const workerId = document.getElementById('workerId').value;
const workerData = {
worker_name: document.getElementById('workerName').value.trim(),
job_type: document.getElementById('jobType').value || 'worker',
join_date: document.getElementById('joinDate').value || null,
salary: document.getElementById('salary').value || null,
annual_leave: document.getElementById('annualLeave').value || 0,
status: document.getElementById('isActiveWorker').checked ? 'active' : 'inactive',
employment_status: document.getElementById('isResigned').checked ? 'resigned' : 'employed',
create_account: document.getElementById('createAccount').checked,
department_id: currentDepartmentId
};
if (!workerData.worker_name) {
showToast('작업자명은 필수 입력 항목입니다.', 'error');
return;
}
try {
let response;
if (workerId) {
response = await window.apiCall(`/workers/${workerId}`, 'PUT', workerData);
} else {
response = await window.apiCall('/workers', 'POST', workerData);
}
if (response && (response.success || response.data)) {
const action = workerId ? '수정' : '등록';
showToast(`작업자가 성공적으로 ${action}되었습니다.`, 'success');
closeWorkerModal();
await loadDepartments();
await loadWorkersByDepartment(currentDepartmentId);
} else {
throw new Error(response?.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('작업자 저장 오류:', error);
showToast(error.message || '작업자 저장 중 오류가 발생했습니다.', 'error');
}
}
// 작업자 삭제 확인
function confirmDeleteWorker(workerId) {
const worker = allWorkers.find(w => w.user_id === workerId);
if (!worker) {
showToast('작업자를 찾을 수 없습니다.', 'error');
return;
}
const confirmMessage = `"${worker.worker_name}" 작업자를 삭제하시겠습니까?\n\n⚠️ 관련된 모든 데이터(작업보고서, 이슈 등)가 함께 삭제됩니다.`;
if (confirm(confirmMessage)) {
deleteWorkerById(workerId);
}
}
// 작업자 삭제 (모달에서)
function deleteWorker() {
if (currentEditingWorker) {
confirmDeleteWorker(currentEditingWorker.user_id);
}
}
// 작업자 삭제 실행
async function deleteWorkerById(workerId) {
try {
const response = await window.apiCall(`/workers/${workerId}`, 'DELETE');
if (response && (response.success || response.message)) {
showToast('작업자가 삭제되었습니다.', 'success');
closeWorkerModal();
await loadDepartments();
await loadWorkersByDepartment(currentDepartmentId);
} else {
throw new Error(response?.error || '삭제 실패');
}
} catch (error) {
console.error('작업자 삭제 오류:', error);
showToast(error.message || '작업자 삭제에 실패했습니다.', 'error');
}
}
// ============================================
// 유틸리티 함수
// ============================================
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
// showToast → api-base.js 전역 사용
// ============================================
// 전역 함수 노출
// ============================================
window.openDepartmentModal = openDepartmentModal;
window.closeDepartmentModal = closeDepartmentModal;
window.saveDepartment = saveDepartment;
window.editDepartment = editDepartment;
window.deleteDepartment = deleteDepartment;
window.confirmDeleteDepartment = confirmDeleteDepartment;
window.selectDepartment = selectDepartment;
window.openWorkerModal = openWorkerModal;
window.closeWorkerModal = closeWorkerModal;
window.saveWorker = saveWorker;
window.editWorker = editWorker;
window.deleteWorker = deleteWorker;
window.confirmDeleteWorker = confirmDeleteWorker;
window.filterWorkers = filterWorkers;

View File

@@ -10,9 +10,6 @@ let canvasImage = null;
// 금일 TBM 작업자 데이터
let todayWorkers = [];
// 금일 출입 신청 데이터
let todayVisitors = [];
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async () => {
@@ -175,8 +172,6 @@ async function loadTodayData() {
// TBM 작업자 데이터 로드
await loadTodayWorkers(today);
// 출입 신청 데이터 로드
await loadTodayVisitors(today);
}
async function loadTodayWorkers(date) {
@@ -212,43 +207,6 @@ async function loadTodayWorkers(date) {
}
}
async function loadTodayVisitors(date) {
try {
// 날짜 형식 확인 (YYYY-MM-DD)
const formattedDate = date.split('T')[0];
const response = await window.apiCall(`/workplace-visits/requests`, 'GET');
if (response && response.success) {
const requests = response.data || [];
// 금일 날짜와 승인된 요청 필터링
todayVisitors = requests.filter(req => {
// UTC 변환 없이 로컬 날짜로 비교
const visitDateObj = new Date(req.visit_date);
const visitYear = visitDateObj.getFullYear();
const visitMonth = String(visitDateObj.getMonth() + 1).padStart(2, '0');
const visitDay = String(visitDateObj.getDate()).padStart(2, '0');
const visitDate = `${visitYear}-${visitMonth}-${visitDay}`;
return visitDate === formattedDate &&
(req.status === 'approved' || req.status === 'training_completed');
}).map(req => ({
workplace_id: req.workplace_id,
visitor_company: req.visitor_company,
visitor_count: req.visitor_count,
visit_time: req.visit_time,
purpose_name: req.purpose_name,
status: req.status
}));
console.log('로드된 방문자:', todayVisitors);
}
} catch (error) {
console.error('출입 신청 데이터 로드 오류:', error);
}
}
// ==================== 지도 렌더링 ====================
function renderMap() {
@@ -260,19 +218,17 @@ function renderMap() {
// 모든 작업장 영역 표시
mapRegions.forEach(region => {
// 해당 작업장의 작업자/방문자 인원 계산
// 해당 작업장의 작업자 인원 계산
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
const totalWorkerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
const totalVisitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
// 영역 그리기
drawWorkplaceRegion(region, totalWorkerCount, totalVisitorCount);
drawWorkplaceRegion(region, totalWorkerCount);
});
}
function drawWorkplaceRegion(region, workerCount, visitorCount) {
function drawWorkplaceRegion(region, workerCount) {
// 사각형 좌표 변환
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
@@ -286,20 +242,12 @@ function drawWorkplaceRegion(region, workerCount, visitorCount) {
// 색상 결정
let fillColor, strokeColor;
const hasActivity = workerCount > 0 || visitorCount > 0;
const hasActivity = workerCount > 0;
if (workerCount > 0 && visitorCount > 0) {
// 둘 다 있음 - 초록
fillColor = 'rgba(34, 197, 94, 0.3)';
strokeColor = 'rgb(34, 197, 94)';
} else if (workerCount > 0) {
// 내부 작업자만 - 파란색
if (workerCount > 0) {
// 작업자 있음 - 파란
fillColor = 'rgba(59, 130, 246, 0.3)';
strokeColor = 'rgb(59, 130, 246)';
} else if (visitorCount > 0) {
// 외부 방문자만 - 보라색
fillColor = 'rgba(168, 85, 247, 0.3)';
strokeColor = 'rgb(168, 85, 247)';
} else {
// 인원 없음 - 회색 테두리만
fillColor = 'rgba(0, 0, 0, 0)'; // 투명
@@ -332,9 +280,8 @@ function drawWorkplaceRegion(region, workerCount, visitorCount) {
ctx.stroke();
// 텍스트
const totalCount = workerCount + visitorCount;
ctx.fillStyle = strokeColor;
ctx.fillText(totalCount.toString(), centerX, centerY);
ctx.fillText(workerCount.toString(), centerX, centerY);
ctx.restore();
} else {
// 인원이 없을 때는 작업장 이름만 표시
@@ -389,7 +336,6 @@ let currentModalWorkplace = null;
function showWorkplaceDetail(workplace) {
currentModalWorkplace = workplace;
const workers = todayWorkers.filter(w => w.workplace_id === workplace.workplace_id);
const visitors = todayVisitors.filter(v => v.workplace_id === workplace.workplace_id);
// 모달 제목
document.getElementById('modalWorkplaceName').textContent = workplace.workplace_name;
@@ -397,15 +343,16 @@ function showWorkplaceDetail(workplace) {
// 요약 카드 업데이트
const totalWorkers = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
const totalVisitors = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
document.getElementById('summaryWorkerCount').textContent = totalWorkers;
document.getElementById('summaryVisitorCount').textContent = totalVisitors;
const summaryVisitorEl = document.getElementById('summaryVisitorCount');
if (summaryVisitorEl) summaryVisitorEl.textContent = '0';
document.getElementById('summaryTaskCount').textContent = workers.length;
// 배지 업데이트
document.getElementById('workerCountBadge').textContent = totalWorkers;
document.getElementById('visitorCountBadge').textContent = totalVisitors;
const visitorBadgeEl = document.getElementById('visitorCountBadge');
if (visitorBadgeEl) visitorBadgeEl.textContent = '0';
// 현황 개요 탭 - 현재 작업 목록
renderCurrentTasks(workers);
@@ -416,9 +363,6 @@ function showWorkplaceDetail(workplace) {
// 작업자 탭
renderWorkersTab(workers);
// 방문자 탭
renderVisitorsTab(visitors);
// 상세 지도 초기화
initDetailMap(workplace);
@@ -529,33 +473,6 @@ function renderWorkersTab(workers) {
container.innerHTML = html;
}
// 방문자 탭 렌더링
function renderVisitorsTab(visitors) {
const container = document.getElementById('externalVisitorsList');
if (visitors.length === 0) {
container.innerHTML = '<p class="empty-message">금일 방문 예정 인원이 없습니다.</p>';
return;
}
let html = '';
visitors.forEach(visitor => {
const statusText = visitor.status === 'training_completed' ? '교육 완료' : '승인됨';
html += `
<div class="visitor-item">
<div class="visitor-item-header">
<p class="visitor-item-title">${escapeHtml(visitor.visitor_company)}</p>
<span class="visitor-item-badge">${parseInt(visitor.visitor_count) || 0}명 • ${statusText}</span>
</div>
<p class="visitor-item-detail">⏰ ${escapeHtml(visitor.visit_time)}</p>
<p class="visitor-item-detail">📋 ${escapeHtml(visitor.purpose_name)}</p>
</div>
`;
});
container.innerHTML = html;
}
// 상세 지도 초기화
async function initDetailMap(workplace) {