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>
This commit is contained in:
Hyungi Ahn
2026-01-20 15:38:17 +09:00
parent 0ec099b493
commit 4d0c4c0801
18 changed files with 3321 additions and 107 deletions

View File

@@ -34,6 +34,9 @@ function processNavbarDom(doc) {
* @param {string} userRole - 현재 사용자의 역할
*/
function filterMenuByRole(doc, userRole) {
// 대소문자 구분 없이 처리
const userRoleLower = (userRole || '').toLowerCase();
const selectors = [
{ role: 'admin', selector: '.admin-only' },
{ role: 'system', selector: '.system-only' },
@@ -41,7 +44,7 @@ function filterMenuByRole(doc, userRole) {
];
selectors.forEach(({ role, selector }) => {
if (userRole !== role && userRole !== 'system') {
if (userRoleLower !== role && userRoleLower !== 'system') {
doc.querySelectorAll(selector).forEach(el => el.remove());
}
});
@@ -54,7 +57,9 @@ function filterMenuByRole(doc, userRole) {
*/
function populateUserInfo(doc, user) {
const displayName = user.name || user.username;
const roleName = ROLE_NAMES[user.role] || ROLE_NAMES.default;
// 대소문자 구분 없이 처리
const roleLower = (user.role || '').toLowerCase();
const roleName = ROLE_NAMES[roleLower] || ROLE_NAMES.default;
const elements = {
'userName': displayName,

View File

@@ -287,100 +287,143 @@ function updateSummaryCard(element, value, unit) {
}
}
// ========== SVG 아이콘 정의 ========== //
const SVG_ICONS = {
complete: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>`,
overtime: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>`,
vacation: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>`,
partial: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>`,
incomplete: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>`,
warning: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>`
};
// ========== 작업 현황 표시 (작업자 중심) ========== //
function displayWorkStatus() {
if (!elements.workStatusContainer) return;
const tableBody = document.getElementById('workStatusTableBody');
if (!tableBody) return;
// 모든 작업자 데이터 가져오기 (작업이 없는 작업자도 포함)
const allWorkers = workersData || [];
if (allWorkers.length === 0) {
elements.workStatusContainer.innerHTML = `
<div class="empty-state">
<div class="empty-icon">👥</div>
<h3>등록된 작업자가 없습니다</h3>
<p>시스템에 작업자가 등록되어 있지 않습니다.</p>
</div>
tableBody.innerHTML = `
<tr>
<td colspan="5" class="empty-state">
<p>등록된 작업자가 없습니다</p>
</td>
</tr>
`;
return;
}
// 작업자별 상황 분석
const workerStatusList = allWorkers.map(worker => {
const todayWork = workData.filter(w => w.worker_id === worker.worker_id);
const totalHours = todayWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
// 휴가/연차 제외한 실제 작업시간 계산
const actualWorkHours = todayWork
.filter(w => w.project_id !== 13) // 연차/휴무 프로젝트 제외
.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
const hasError = todayWork.some(w => w.work_status_id === 2);
// 정규 작업과 에러 작업 건수 분리
const regularWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id !== 2).length;
const errorWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id === 2).length;
// 상태 판단 로직 (개선된 버전)
let status = 'incomplete';
let statusText = '미입력';
let statusBadge = '미입력';
let statusClass = 'incomplete';
let vacationType = null;
// 휴가 처리된 경우 확인 (프로젝트 ID 13 = "연차/휴무" 또는 설명에 휴가 키워드)
const hasVacationRecord = todayWork.some(w =>
const hasVacationRecord = todayWork.some(w =>
w.project_id === 13 || // 연차/휴무 프로젝트
(w.description && (
w.description.includes('연차') ||
w.description.includes('반차') ||
w.description.includes('연차') ||
w.description.includes('반차') ||
w.description.includes('휴가')
))
);
// 연차/휴무 프로젝트의 시간 계산
const vacationHours = todayWork
.filter(w => w.project_id === 13)
.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
if (totalHours > 12) {
status = 'overtime-warning';
statusText = '초과근무 확인필요';
statusBadge = '확인필요';
statusClass = 'warning';
} else if (hasVacationRecord && vacationHours > 0) {
// 연차/휴무 시간에 따른 상태 결정
if (vacationHours === 8) {
status = 'vacation-full';
statusText = '연차';
statusBadge = '연차';
statusClass = 'vacation';
} else if (vacationHours === 6) {
status = 'vacation-half-half';
statusText = '조퇴';
statusBadge = '조퇴';
statusClass = 'vacation';
} else if (vacationHours === 4) {
status = 'vacation-half';
statusText = '반차';
statusBadge = '반차';
statusClass = 'vacation';
} else if (vacationHours === 2) {
status = 'vacation-quarter';
statusText = '반반차';
statusBadge = '반반차';
statusClass = 'vacation';
}
} else if (totalHours > 8) {
// 8시간 초과 - 연장근로
status = 'overtime';
statusText = '연장근로';
statusBadge = '연장근로';
statusClass = 'overtime';
} else if (totalHours === 8) {
// 정확히 8시간 - 정시근로
status = 'complete';
statusText = '정시근로';
statusBadge = '정시근로';
statusClass = 'success';
} else if (totalHours > 0) {
// 0시간 초과 8시간 미만 - 부분 입력
status = 'partial';
statusText = '부분 입력';
statusBadge = '부분입력';
statusClass = 'info';
// 휴가 처리 필요 여부 판단
if (totalHours === 0) {
vacationType = 'full';
@@ -394,9 +437,10 @@ function displayWorkStatus() {
status = 'incomplete';
statusText = '미입력';
statusBadge = '미입력';
statusClass = 'incomplete';
vacationType = 'full';
}
return {
...worker,
todayWork,
@@ -408,79 +452,92 @@ function displayWorkStatus() {
status,
statusText,
statusBadge,
statusClass,
vacationType
};
});
elements.workStatusContainer.innerHTML = `
<div class="worker-status-list">
<div class="worker-status-header">
<div class="header-title">
<h3>작업자별 현황</h3>
<span class="header-date">${selectedDate}</span>
</div>
<div class="status-legend">
<span class="legend-item legend-complete">정시근로</span>
<span class="legend-item legend-overtime">연장근로</span>
<span class="legend-item legend-vacation">휴가</span>
<span class="legend-item legend-partial">부분입력</span>
<span class="legend-item legend-incomplete">미입력</span>
</div>
</div>
<div class="worker-status-rows">
${workerStatusList.map(worker => `
<div class="worker-status-row ${worker.status}" data-worker-id="${worker.worker_id}">
<div class="worker-basic-info">
<div class="worker-avatar">
<span>${worker.worker_name.charAt(0)}</span>
</div>
<div class="worker-details">
<h4 class="worker-name">${worker.worker_name}</h4>
<p class="worker-job">${worker.job_type || '작업자'}</p>
</div>
</div>
<div class="worker-status-indicator">
<span class="status-badge status-${worker.status}">${worker.statusBadge}</span>
</div>
<div class="worker-stats-inline">
<div class="stat-item">
<span class="stat-label">작업시간</span>
<span class="stat-value ${worker.actualWorkHours > 12 ? 'warning' : ''}">${worker.actualWorkHours.toFixed(1)}h</span>
</div>
<div class="stat-item">
<span class="stat-label">정규</span>
<span class="stat-value">${worker.regularWorkCount}건</span>
</div>
${worker.errorWorkCount > 0 ? `
<div class="stat-item error">
<span class="stat-label">에러</span>
<span class="stat-value">${worker.errorWorkCount}건</span>
</div>
` : ''}
</div>
<div class="worker-actions-inline">
<button class="btn btn-sm btn-primary worker-edit-btn" onclick="openWorkerModal(${worker.worker_id}, '${worker.worker_name}')">
작업입력
</button>
${worker.vacationType ? `
<button class="btn btn-sm btn-secondary vacation-btn" onclick="handleVacation(${worker.worker_id}, '${worker.vacationType}')">
${worker.vacationType === 'full' ? '연차처리' : worker.vacationType === 'half' ? '반차처리' : '반반차처리'}
</button>
` : ''}
${worker.status === 'overtime-warning' ? `
<button class="btn btn-sm btn-warning confirm-overtime-btn" onclick="confirmOvertime(${worker.worker_id})">
정상확인
</button>
` : ''}
</div>
// 테이블 행 렌더링
tableBody.innerHTML = workerStatusList.map(worker => {
// 상태에 따른 SVG 아이콘 선택
let iconKey = 'incomplete';
if (worker.status === 'overtime-warning') iconKey = 'warning';
else if (worker.status.startsWith('vacation')) iconKey = 'vacation';
else if (worker.status === 'overtime') iconKey = 'overtime';
else if (worker.status === 'complete') iconKey = 'complete';
else if (worker.status === 'partial') iconKey = 'partial';
return `
<tr data-worker-id="${worker.worker_id}">
<td data-label="작업자" class="worker-info">
<div class="worker-avatar">
<span>${worker.worker_name.charAt(0)}</span>
</div>
`).join('')}
</div>
</div>
`;
<div class="worker-details">
<div class="worker-name">${worker.worker_name}</div>
<div class="worker-job">${worker.job_type || '작업자'}</div>
</div>
</td>
<td data-label="상태" class="worker-status">
<span class="status-badge status-${worker.statusClass}">
${SVG_ICONS[iconKey]}
<span class="status-text">${worker.statusBadge}</span>
</span>
</td>
<td data-label="작업시간" class="worker-hours">
<span class="hours-value ${worker.actualWorkHours > 12 ? 'hours-warning' : ''}">${worker.actualWorkHours.toFixed(1)}h</span>
</td>
<td data-label="작업건수" class="worker-tasks">
<div class="tasks-summary">
<span class="task-count">
<span class="task-label">정규:</span>
<span class="task-value">${worker.regularWorkCount}건</span>
</span>
${worker.errorWorkCount > 0 ? `
<span class="task-count task-error">
<span class="task-label">에러:</span>
<span class="task-value">${worker.errorWorkCount}건</span>
</span>
` : ''}
</div>
</td>
<td data-label="액션" class="worker-actions">
<div class="action-buttons">
<button class="action-btn btn-edit" onclick="openWorkerModal(${worker.worker_id}, '${worker.worker_name}')" title="작업입력">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
<span class="action-text">작업입력</span>
</button>
${worker.vacationType ? `
<button class="action-btn btn-vacation" onclick="handleVacation(${worker.worker_id}, '${worker.vacationType}')" title="${worker.vacationType === 'full' ? '연차처리' : worker.vacationType === 'half' ? '반차처리' : '반반차처리'}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<span class="action-text">${worker.vacationType === 'full' ? '연차' : worker.vacationType === 'half' ? '반차' : '반반차'}</span>
</button>
` : ''}
${worker.status === 'overtime-warning' ? `
<button class="action-btn btn-confirm" onclick="confirmOvertime(${worker.worker_id})" title="정상확인">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span class="action-text">정상확인</span>
</button>
` : ''}
</div>
</td>
</tr>
`;
}).join('');
}
function groupWorkDataByProject() {

View File

@@ -0,0 +1,338 @@
// 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;