Files
TK-FB-Project/web-ui/js/page-access-management.js
Hyungi Ahn 4d0c4c0801 feat: TBM 시스템 구축 및 페이지 권한 관리 기능 추가
## 주요 변경사항

### 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>
2026-01-20 15:38:17 +09:00

339 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;