## 주요 변경사항 ### 1. TBM (Tool Box Meeting) 시스템 구축 - **데이터베이스 스키마** (5개 테이블 생성) - tbm_sessions: TBM 세션 관리 - tbm_team_assignments: 팀 구성 관리 - tbm_safety_checks: 안전 체크리스트 마스터 (17개 항목) - tbm_safety_records: 안전 체크 기록 - team_handovers: 작업 인계 관리 - **API 엔드포인트** (17개) - TBM 세션 CRUD - 팀 구성 관리 - 안전 체크리스트 - 작업 인계 - 통계 및 리포트 - **프론트엔드** - TBM 관리 페이지 (/pages/work/tbm.html) - 모달 기반 UI (세션 생성, 팀 구성, 안전 체크) ### 2. 페이지 권한 관리 시스템 - 페이지별 접근 권한 설정 기능 - 관리자 페이지 (/pages/admin/page-access.html) - 사용자별 페이지 권한 부여/회수 - TBM 페이지 등록 및 권한 연동 ### 3. 네비게이션 role 표시 버그 수정 - load-navbar.js: case-insensitive role 매칭 적용 - JWT의 "Admin" role이 "관리자"로 정상 표시 - admin-only 메뉴 항목 정상 표시 ### 4. 대시보드 개선 - 작업 현황 테이블 가독성 향상 - 고대비 색상 및 명확한 구분선 적용 - 이모지 제거 및 SVG 아이콘 적용 ### 5. 문서화 - TBM 배포 가이드 작성 (docs/TBM_DEPLOYMENT_GUIDE.md) - 데이터베이스 스키마 상세 기록 - 배포 절차 및 체크리스트 제공 ## 기술 스택 - Backend: Node.js, Express, MySQL - Frontend: Vanilla JavaScript, HTML5, CSS3 - Database: MySQL (InnoDB) ## 파일 변경사항 ### 신규 파일 - api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js - api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js - api.hyungi.net/models/tbmModel.js - api.hyungi.net/models/pageAccessModel.js - api.hyungi.net/controllers/tbmController.js - api.hyungi.net/controllers/pageAccessController.js - api.hyungi.net/routes/tbmRoutes.js - web-ui/pages/work/tbm.html - web-ui/pages/admin/page-access.html - web-ui/js/page-access-management.js - docs/TBM_DEPLOYMENT_GUIDE.md ### 수정 파일 - api.hyungi.net/config/routes.js (TBM 라우트 추가) - web-ui/js/load-navbar.js (role 매칭 버그 수정) - web-ui/pages/admin/workers.html (HTML 구조 수정) - web-ui/pages/dashboard.html (이모지 제거) - web-ui/css/design-system.css (색상 팔레트 추가) - web-ui/css/modern-dashboard.css (가독성 개선) - web-ui/js/modern-dashboard.js (SVG 아이콘 적용) ## 배포 시 주의사항 ⚠️ 본 서버 배포 시 반드시 마이그레이션 실행 필요: ```bash npm run db:migrate ``` 상세한 배포 절차는 docs/TBM_DEPLOYMENT_GUIDE.md 참조 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
339 lines
11 KiB
JavaScript
339 lines
11 KiB
JavaScript
// page-access-management.js - 페이지 권한 관리
|
||
|
||
// 전역 변수
|
||
let allUsers = [];
|
||
let allPages = [];
|
||
let currentUserId = null;
|
||
let currentFilter = 'all';
|
||
|
||
// DOM이 로드되면 초기화
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
console.log('🚀 페이지 권한 관리 시스템 초기화');
|
||
|
||
// API 함수가 로드될 때까지 대기
|
||
let retryCount = 0;
|
||
while (!window.apiCall && retryCount < 50) {
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
retryCount++;
|
||
}
|
||
|
||
if (!window.apiCall) {
|
||
showToast('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
|
||
return;
|
||
}
|
||
|
||
// 이벤트 리스너 설정
|
||
setupEventListeners();
|
||
|
||
// 데이터 로드
|
||
await loadInitialData();
|
||
});
|
||
|
||
// 이벤트 리스너 설정
|
||
function setupEventListeners() {
|
||
// 필터 버튼
|
||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||
e.target.classList.add('active');
|
||
currentFilter = e.target.dataset.filter;
|
||
filterUsers();
|
||
});
|
||
});
|
||
|
||
// 저장 버튼
|
||
const saveBtn = document.getElementById('savePageAccessBtn');
|
||
if (saveBtn) {
|
||
saveBtn.addEventListener('click', savePageAccess);
|
||
}
|
||
}
|
||
|
||
// 초기 데이터 로드
|
||
async function loadInitialData() {
|
||
try {
|
||
// 페이지 목록 로드
|
||
const pagesResponse = await window.apiCall('/pages');
|
||
if (pagesResponse && pagesResponse.success) {
|
||
allPages = pagesResponse.data;
|
||
console.log('✅ 페이지 목록 로드:', allPages.length + '개');
|
||
}
|
||
|
||
// 사용자 목록 로드 - 계정이 있는 작업자만
|
||
const workersResponse = await window.apiCall('/workers?limit=1000');
|
||
if (workersResponse) {
|
||
const workers = Array.isArray(workersResponse) ? workersResponse : (workersResponse.data || []);
|
||
|
||
// user_id가 있고 활성 상태인 작업자만 필터링
|
||
const usersWithAccounts = workers.filter(w => w.user_id && w.is_active);
|
||
|
||
// 각 사용자의 페이지 권한 수 조회
|
||
allUsers = await Promise.all(usersWithAccounts.map(async (worker) => {
|
||
try {
|
||
const accessResponse = await window.apiCall(`/users/${worker.user_id}/page-access`);
|
||
const grantedPagesCount = accessResponse && accessResponse.success
|
||
? accessResponse.data.pageAccess.filter(p => p.can_access).length
|
||
: 0;
|
||
|
||
return {
|
||
user_id: worker.user_id,
|
||
username: worker.username || 'N/A',
|
||
name: worker.name || worker.worker_name,
|
||
role_name: worker.role_name || 'User',
|
||
worker_name: worker.worker_name,
|
||
worker_id: worker.worker_id,
|
||
granted_pages_count: grantedPagesCount
|
||
};
|
||
} catch (error) {
|
||
console.error(`권한 조회 오류 (user_id: ${worker.user_id}):`, error);
|
||
return {
|
||
...worker,
|
||
granted_pages_count: 0
|
||
};
|
||
}
|
||
}));
|
||
|
||
console.log('✅ 사용자 목록 로드:', allUsers.length + '명');
|
||
displayUsers();
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ 데이터 로드 오류:', error);
|
||
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// 사용자 목록 표시
|
||
function displayUsers() {
|
||
const tbody = document.getElementById('usersTableBody');
|
||
const emptyState = document.getElementById('emptyState');
|
||
|
||
if (allUsers.length === 0) {
|
||
tbody.innerHTML = '';
|
||
emptyState.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
emptyState.style.display = 'none';
|
||
|
||
const filteredUsers = filterUsersByStatus();
|
||
|
||
if (filteredUsers.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="6" style="text-align: center; padding: 2rem; color: #6b7280;">
|
||
<p>필터 조건에 맞는 사용자가 없습니다.</p>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = filteredUsers.map(user => `
|
||
<tr>
|
||
<td>
|
||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||
<div style="width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem;">
|
||
${(user.name || user.username).charAt(0)}
|
||
</div>
|
||
<span style="font-weight: 600;">${user.name || user.username}</span>
|
||
</div>
|
||
</td>
|
||
<td>${user.username}</td>
|
||
<td>
|
||
<span class="badge ${user.role_name === 'Admin' ? 'badge-warning' : 'badge-info'}">
|
||
${user.role_name}
|
||
</span>
|
||
</td>
|
||
<td>${user.worker_name || '-'}</td>
|
||
<td>
|
||
<span style="font-weight: 600; color: ${user.granted_pages_count > 0 ? '#16a34a' : '#6b7280'};">
|
||
${user.granted_pages_count}개
|
||
</span>
|
||
<span style="color: #9ca3af;"> / ${allPages.length}개</span>
|
||
</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-primary" onclick="openPageAccessModal(${user.user_id})">
|
||
권한 설정
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
// 사용자 필터링
|
||
function filterUsersByStatus() {
|
||
if (currentFilter === 'all') {
|
||
return allUsers;
|
||
} else if (currentFilter === 'with-access') {
|
||
return allUsers.filter(u => u.granted_pages_count > 0);
|
||
} else if (currentFilter === 'no-access') {
|
||
return allUsers.filter(u => u.granted_pages_count === 0);
|
||
}
|
||
return allUsers;
|
||
}
|
||
|
||
function filterUsers() {
|
||
displayUsers();
|
||
}
|
||
|
||
// 페이지 권한 설정 모달 열기
|
||
async function openPageAccessModal(userId) {
|
||
currentUserId = userId;
|
||
const user = allUsers.find(u => u.user_id === userId);
|
||
|
||
if (!user) {
|
||
showToast('사용자 정보를 찾을 수 없습니다.', 'error');
|
||
return;
|
||
}
|
||
|
||
// 모달 열기
|
||
document.getElementById('pageAccessModal').style.display = 'flex';
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
// 사용자 정보 표시
|
||
document.getElementById('modalUserInitial').textContent = (user.name || user.username).charAt(0);
|
||
document.getElementById('modalUserName').textContent = user.name || user.username;
|
||
document.getElementById('modalUsername').textContent = user.username;
|
||
document.getElementById('modalWorkerName').textContent = user.worker_name || '작업자 정보 없음';
|
||
|
||
// 페이지 목록 로드
|
||
try {
|
||
const response = await window.apiCall(`/users/${userId}/page-access`);
|
||
|
||
if (response && response.success) {
|
||
const pageAccess = response.data.pageAccess;
|
||
renderPageList(pageAccess);
|
||
} else {
|
||
showToast('페이지 권한 정보를 불러올 수 없습니다.', 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('페이지 권한 조회 오류:', error);
|
||
showToast('페이지 권한 정보를 불러오는 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// 페이지 목록 렌더링
|
||
function renderPageList(pageAccess) {
|
||
const container = document.getElementById('pageListContainer');
|
||
|
||
// 카테고리별로 그룹화
|
||
const grouped = {};
|
||
pageAccess.forEach(page => {
|
||
const category = page.category || 'common';
|
||
if (!grouped[category]) {
|
||
grouped[category] = [];
|
||
}
|
||
grouped[category].push(page);
|
||
});
|
||
|
||
const categoryNames = {
|
||
'dashboard': '대시보드',
|
||
'management': '관리',
|
||
'common': '공통',
|
||
'admin': '관리자',
|
||
'work': '작업',
|
||
'guest': '게스트'
|
||
};
|
||
|
||
container.innerHTML = Object.keys(grouped).map(category => `
|
||
<div style="margin-bottom: 1rem;">
|
||
<div style="font-weight: 600; font-size: 0.875rem; color: #6b7280; padding: 0.5rem; background: #f9fafb; border-radius: 0.375rem; margin-bottom: 0.5rem;">
|
||
${categoryNames[category] || category}
|
||
</div>
|
||
${grouped[category].map(page => `
|
||
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6; display: flex; align-items: center; justify-content: space-between;">
|
||
<label style="display: flex; align-items: center; gap: 0.75rem; cursor: pointer; flex: 1;">
|
||
<input
|
||
type="checkbox"
|
||
class="page-checkbox"
|
||
data-page-id="${page.page_id}"
|
||
${page.can_access || page.is_default ? 'checked' : ''}
|
||
${page.is_default ? 'disabled' : ''}
|
||
style="width: 18px; height: 18px; cursor: pointer;"
|
||
/>
|
||
<div style="flex: 1;">
|
||
<div style="font-weight: 500; color: #111827;">${page.page_name}</div>
|
||
<div style="font-size: 0.75rem; color: #9ca3af;">${page.page_path}</div>
|
||
</div>
|
||
</label>
|
||
${page.is_default ? '<span style="font-size: 0.75rem; color: #16a34a; font-weight: 600;">기본 권한</span>' : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// 페이지 권한 저장
|
||
async function savePageAccess() {
|
||
if (!currentUserId) return;
|
||
|
||
const checkboxes = document.querySelectorAll('.page-checkbox:not([disabled]):checked');
|
||
const pageIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.pageId));
|
||
|
||
try {
|
||
document.getElementById('savePageAccessBtn').disabled = true;
|
||
document.getElementById('savePageAccessBtn').textContent = '저장 중...';
|
||
|
||
const response = await window.apiCall(
|
||
`/users/${currentUserId}/page-access`,
|
||
'POST',
|
||
{ pageIds, canAccess: true }
|
||
);
|
||
|
||
if (response && response.success) {
|
||
showToast('페이지 권한이 저장되었습니다.', 'success');
|
||
closePageAccessModal();
|
||
await loadInitialData(); // 목록 새로고침
|
||
} else {
|
||
throw new Error(response.error || '저장에 실패했습니다.');
|
||
}
|
||
} catch (error) {
|
||
console.error('페이지 권한 저장 오류:', error);
|
||
showToast('페이지 권한 저장 중 오류가 발생했습니다.', 'error');
|
||
} finally {
|
||
document.getElementById('savePageAccessBtn').disabled = false;
|
||
document.getElementById('savePageAccessBtn').textContent = '저장';
|
||
}
|
||
}
|
||
|
||
// 모달 닫기
|
||
function closePageAccessModal() {
|
||
document.getElementById('pageAccessModal').style.display = 'none';
|
||
document.body.style.overflow = 'auto';
|
||
currentUserId = null;
|
||
}
|
||
|
||
// 토스트 알림
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
const container = document.getElementById('toastContainer');
|
||
if (!container) return;
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
|
||
const iconMap = {
|
||
success: '✅',
|
||
error: '❌',
|
||
warning: '⚠️',
|
||
info: 'ℹ️'
|
||
};
|
||
|
||
toast.innerHTML = `
|
||
<div class="toast-icon">${iconMap[type] || 'ℹ️'}</div>
|
||
<div class="toast-message">${message}</div>
|
||
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
|
||
`;
|
||
|
||
container.appendChild(toast);
|
||
|
||
setTimeout(() => {
|
||
if (toast.parentElement) {
|
||
toast.remove();
|
||
}
|
||
}, duration);
|
||
}
|
||
|
||
// 전역 함수로 export
|
||
window.openPageAccessModal = openPageAccessModal;
|
||
window.closePageAccessModal = closePageAccessModal;
|